Compare commits
4 Commits
main
...
879a46ccac
| Author | SHA1 | Date | |
|---|---|---|---|
| 879a46ccac | |||
| 8912968d75 | |||
| e3579011e8 | |||
| e68638ec9c |
@@ -38,13 +38,11 @@ You will also need docker installed on whatever host you plan to run the Convex
|
||||
Copy the example environment variable files and paste them in the same directory named `.env`.
|
||||
|
||||
Environment variables for Next Application
|
||||
|
||||
```bash
|
||||
cp ./apps/next/env.example ./apps/next/.env
|
||||
```
|
||||
|
||||
Environment variables for Self Hosting Convex & Website with Docker
|
||||
|
||||
```bash
|
||||
cp ./docker/env.example ./docker/.env
|
||||
```
|
||||
@@ -52,7 +50,6 @@ cp ./docker/env.example ./docker/.env
|
||||
### Start self hosted convex & Next Web Application
|
||||
|
||||
The basic gist is to run the commands below after you have filled out the environment variables you plan to use, but you should ultimately follow the [guide they provide](https://github.com/get-convex/convex-backend/tree/main/self-hosted)
|
||||
|
||||
```bash
|
||||
cd ./docker
|
||||
sudo docker compose up -d
|
||||
@@ -63,9 +60,9 @@ sudo docker compose exec convex-backend ./generate_admin_key.sh
|
||||
|
||||
Run
|
||||
|
||||
```bash
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
```
|
||||
|
||||
### Fin
|
||||
|
||||
|
||||
@@ -1,45 +1,25 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Tech Tracker",
|
||||
"owner": "gib",
|
||||
"name": "techtracker-expo",
|
||||
"slug": "techtracker-expo",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "org.gbrown.techtrackerexpo",
|
||||
"scheme": "techtrackerexpo",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/images/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#2e2f3d"
|
||||
},
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"usesAppleSignIn": true,
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.gbrown.techtracker",
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSLocationWhenInUseUsageDescription": "This app uses your location in order to allow you to share your location in chat.",
|
||||
"NSCameraUsageDescription": "This app uses your camera to take photos & send them in the chat."
|
||||
}
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#2e2f3d"
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"permissions": [
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.VIBRATE",
|
||||
"android.permission.INTERNET"
|
||||
],
|
||||
"package": "com.gbrown.techtracker"
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
@@ -47,7 +27,6 @@
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-apple-authentication",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -59,27 +38,6 @@
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-secure-store",
|
||||
{
|
||||
"faceIDPermission": "Allow $(PRODUCT_NAME) to access your FaceID biometric data."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
|
||||
}
|
||||
],
|
||||
[
|
||||
"@sentry/react-native/expo",
|
||||
{
|
||||
"url": "https://sentry.gbrown.org",
|
||||
"note": "Use SENTRY_AUTH_TOKEN env to authenticate with Sentry.",
|
||||
"project": "tech-tracker-expo",
|
||||
"organization": "gib"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 386 KiB |
@@ -1,5 +0,0 @@
|
||||
// const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { getSentryExpoConfig } = require('@sentry/react-native/metro');
|
||||
// const config = getDefaultConfig(__dirname);
|
||||
const config = getSentryExpoConfig(__dirname);
|
||||
module.exports = config;
|
||||
@@ -3,46 +3,42 @@
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"dev": "expo start",
|
||||
"dev:tunnel": "expo start --tunnel",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.6.0",
|
||||
"@react-navigation/elements": "^2.7.1",
|
||||
"@react-navigation/native": "^7.1.19",
|
||||
"@sentry/react-native": "^7.4.0",
|
||||
"expo": "~54.0.20",
|
||||
"expo-apple-authentication": "~8.0.7",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.4",
|
||||
"expo-constants": "~18.0.8",
|
||||
"expo-font": "~14.0.8",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image": "~3.0.8",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-location": "~19.0.7",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-router": "~6.0.2",
|
||||
"expo-splash-screen": "~31.0.9",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-web-browser": "~15.0.7",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.3",
|
||||
"react-native-safe-area-context": "~5.6.1",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"react-native-reanimated": "~4.1.0",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.2",
|
||||
"react-native-worklets": "0.5.1"
|
||||
"react-native-web": "~0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.17",
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint-config-expo": "~10.0.0"
|
||||
},
|
||||
"private": true
|
||||
|
||||
@@ -15,24 +15,19 @@ export default function TabLayout() {
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name='index'
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name='house.fill' color={color} />
|
||||
),
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name='explore'
|
||||
name="explore"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name='paperplane.fill' color={color} />
|
||||
),
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
@@ -15,82 +16,71 @@ export default function TabTwoScreen() {
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color='#808080'
|
||||
name='chevron.left.forwardslash.chevron.right'
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type='title'
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>
|
||||
This app includes example code to help you get started.
|
||||
</ThemedText>
|
||||
<Collapsible title='File-based routing'>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '}
|
||||
and{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/explore.tsx</ThemedText>
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href='https://docs.expo.dev/router/introduction'>
|
||||
<ThemedText type='link'>Learn more</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title='Android, iOS, and web support'>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the
|
||||
web version, press <ThemedText type='defaultSemiBold'>w</ThemedText>{' '}
|
||||
in the terminal running this project.
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title='Images'>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the{' '}
|
||||
<ThemedText type='defaultSemiBold'>@2x</ThemedText> and{' '}
|
||||
<ThemedText type='defaultSemiBold'>@3x</ThemedText> suffixes to
|
||||
provide files for different screen densities
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('assets/images/react-logo.png')}
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href='https://reactnative.dev/docs/images'>
|
||||
<ThemedText type='link'>Learn more</ThemedText>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title='Light and dark mode components'>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type='defaultSemiBold'>useColorScheme()</ThemedText> hook
|
||||
lets you inspect what the user's current color scheme is, and so
|
||||
you can adjust UI colors accordingly.
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href='https://docs.expo.dev/develop/user-interface/color-themes/'>
|
||||
<ThemedText type='link'>Learn more</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title='Animations'>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
components/HelloWave.tsx
|
||||
</ThemedText>{' '}
|
||||
component uses the powerful{' '}
|
||||
<ThemedText type='defaultSemiBold' style={{ fontFamily: Fonts.mono }}>
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
@@ -98,10 +88,7 @@ export default function TabTwoScreen() {
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The{' '}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
components/ParallaxScrollView.tsx
|
||||
</ThemedText>{' '}
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { HelloWave } from '@/components/hello-wave';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
@@ -12,22 +13,20 @@ export default function HomeScreen() {
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('assets/images/partial-react-logo.png')}
|
||||
source={require('@/assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}
|
||||
>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type='title'>Welcome!</ThemedText>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type='subtitle'>Step 1: Try it</ThemedText>
|
||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit{' '}
|
||||
<ThemedText type='defaultSemiBold'>app/(tabs)/index.tsx</ThemedText>{' '}
|
||||
to see changes. Press{' '}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||
Press{' '}
|
||||
<ThemedText type="defaultSemiBold">
|
||||
{Platform.select({
|
||||
ios: 'cmd + d',
|
||||
android: 'cmd + m',
|
||||
@@ -38,26 +37,22 @@ export default function HomeScreen() {
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<Link href='/modal'>
|
||||
<Link href="/modal">
|
||||
<Link.Trigger>
|
||||
<ThemedText type='subtitle'>Step 2: Explore</ThemedText>
|
||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
|
||||
<Link.MenuAction
|
||||
title='Action'
|
||||
icon='cube'
|
||||
onPress={() => alert('Action pressed')}
|
||||
/>
|
||||
<Link.MenuAction
|
||||
title='Share'
|
||||
icon='square.and.arrow.up'
|
||||
title="Share"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={() => alert('Share pressed')}
|
||||
/>
|
||||
<Link.Menu title='More' icon='ellipsis'>
|
||||
<Link.Menu title="More" icon="ellipsis">
|
||||
<Link.MenuAction
|
||||
title='Delete'
|
||||
icon='trash'
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => alert('Delete pressed')}
|
||||
/>
|
||||
@@ -70,16 +65,13 @@ export default function HomeScreen() {
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type='subtitle'>Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
{`When you're ready, run `}
|
||||
<ThemedText type='defaultSemiBold'>
|
||||
npm run reset-project
|
||||
</ThemedText>{' '}
|
||||
to get a fresh <ThemedText type='defaultSemiBold'>app</ThemedText>{' '}
|
||||
directory. This will move the current{' '}
|
||||
<ThemedText type='defaultSemiBold'>app</ThemedText> to{' '}
|
||||
<ThemedText type='defaultSemiBold'>app-example</ThemedText>.
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
import {
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
ThemeProvider,
|
||||
} from '@react-navigation/native';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import * as Sentry from '@sentry/react-native';
|
||||
import { ConvexProvider, ConvexReactClient } from 'convex/react';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://ff2e19b7c72ee50463c6c66b5bef7ce0@sentry.gbrown.org/8',
|
||||
sendDefaultPii: true,
|
||||
tracesSampleRate: 1.0,
|
||||
profilesSampleRate: 1.0,
|
||||
});
|
||||
|
||||
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
|
||||
const RootLayout = () => {
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ConvexProvider client={convex}>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name='modal'
|
||||
options={{ presentation: 'modal', title: 'Modal' }}
|
||||
/>
|
||||
</Stack>
|
||||
<StatusBar style='auto' />
|
||||
</ThemeProvider>
|
||||
</ConvexProvider>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
export default Sentry.wrap(RootLayout);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import { ThemedView } from '@/components/themed-view';
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type='title'>This is a modal</ThemedText>
|
||||
<Link href='/' dismissTo style={styles.link}>
|
||||
<ThemedText type='link'>Go to home screen</ThemedText>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import {
|
||||
openBrowserAsync,
|
||||
WebBrowserPresentationStyle,
|
||||
} from 'expo-web-browser';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & {
|
||||
href: Href & string;
|
||||
};
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target='_blank'
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
|
||||
@@ -12,8 +12,7 @@ export function HelloWave() {
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
|
||||
@@ -34,15 +34,11 @@ export default function ParallaxScrollView({
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[2, 1, 1],
|
||||
),
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -52,15 +48,13 @@ export default function ParallaxScrollView({
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
|
||||
@@ -7,16 +7,8 @@ export type ThemedViewProps = ViewProps & {
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
...otherProps
|
||||
}: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor(
|
||||
{ light: lightColor, dark: darkColor },
|
||||
'background',
|
||||
);
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@ import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({
|
||||
children,
|
||||
title,
|
||||
}: PropsWithChildren & { title: string }) {
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
@@ -19,17 +16,16 @@ export function Collapsible({
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name='chevron.right'
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight='medium'
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type='defaultSemiBold'>{title}</ThemedText>
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function IconSymbol({
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode='scaleAspectFit'
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
|
||||
@@ -5,10 +5,7 @@ import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<
|
||||
SymbolViewProps['name'],
|
||||
ComponentProps<typeof MaterialIcons>['name']
|
||||
>;
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
@@ -40,12 +37,5 @@ export function IconSymbol({
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<MaterialIcons
|
||||
color={color}
|
||||
size={size}
|
||||
name={MAPPING[name]}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
}
|
||||
|
||||
@@ -47,8 +47,7 @@ export const Fonts = Platform.select({
|
||||
web: {
|
||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
rounded:
|
||||
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"assets/*": ["./assets/*"],
|
||||
"@/*": ["./src/*"],
|
||||
"~/*": ["../../packages/backend/*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
8
apps/next/.dockerignore
Normal file
8
apps/next/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
coverage
|
||||
*.log
|
||||
docker-compose*.yml
|
||||
host/
|
||||
3
apps/next/.gitignore
vendored
3
apps/next/.gitignore
vendored
@@ -45,6 +45,3 @@ next-env.d.ts
|
||||
|
||||
# Ignored for the template, you probably want to remove it:
|
||||
package-lock.json
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
### Server Variables ###
|
||||
# Next
|
||||
NODE_ENV=
|
||||
SKIP_ENV_VALIDATION=
|
||||
SITE_URL=
|
||||
# Convex
|
||||
CONVEX_SELF_HOSTED_URL=
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=
|
||||
NEXT_PUBLIC_CONVEX_URL=
|
||||
SETUP_SCRIPT_RAN=
|
||||
# Sentry
|
||||
SENTRY_AUTH_TOKEN=
|
||||
CI=
|
||||
|
||||
### Client Variables ###
|
||||
# Next
|
||||
NEXT_PUBLIC_SITE_URL=
|
||||
# Convex
|
||||
NEXT_PUBLIC_CONVEX_URL=
|
||||
# Sentry
|
||||
# Next # Default Values:
|
||||
NEXT_PUBLIC_SITE_URL='http://localhost:3000'
|
||||
# Sentry # Default Values
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_URL=
|
||||
NEXT_PUBLIC_SENTRY_ORG=
|
||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME=
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from './src/env.js';
|
||||
import './src/env.js';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
import { withPlausibleProxy } from 'next-plausible';
|
||||
|
||||
@@ -32,12 +32,12 @@ const nextConfig = withPlausibleProxy({
|
||||
const sentryConfig = {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
org: env.NEXT_PUBLIC_SENTRY_ORG,
|
||||
project: env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
||||
sentryUrl: env.NEXT_PUBLIC_SENTRY_URL,
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
org: 'gib',
|
||||
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
||||
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !env.CI,
|
||||
silent: !process.env.CI,
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev:tunnel": "next dev --turbo",
|
||||
"dev:slow": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
@@ -14,9 +13,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "^0.0.81",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@@ -24,41 +22,39 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@sentry/nextjs": "^10.22.0",
|
||||
"@sentry/nextjs": "^10.11.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.28.0",
|
||||
"convex": "^1.27.0",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "^15.5.6",
|
||||
"next": "^15.5.3",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"require-in-the-middle": "^7.5.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"typescript-eslint": "^8.43.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.12"
|
||||
"zod": "^4.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@types/node": "^20.19.23",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^20.19.13",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"dotenv": "^16.6.1",
|
||||
"eslint-config-next": "^15.5.6",
|
||||
"eslint-config-next": "^15.5.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.3.8"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 845 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 425 KiB |
@@ -1,9 +1,9 @@
|
||||
import { env } from './src/env.js';
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import './src/env.js';
|
||||
|
||||
Sentry.init({
|
||||
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 1,
|
||||
enableLogs: true,
|
||||
debug: false,
|
||||
});
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: 'Forgot Password',
|
||||
};
|
||||
};
|
||||
|
||||
const ProfileLayout = ({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
export default ProfileLayout;
|
||||
@@ -1,298 +0,0 @@
|
||||
'use client';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
SubmitButton,
|
||||
InputOTPSeparator,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
import { PASSWORD_MIN, PASSWORD_MAX } from '@/lib/types';
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z.email({ message: 'Invalid email.' }),
|
||||
});
|
||||
|
||||
const resetVerificationSchema = z
|
||||
.object({
|
||||
code: z.string({ message: 'Invalid code.' }),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(PASSWORD_MIN, {
|
||||
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||
})
|
||||
.max(PASSWORD_MAX, {
|
||||
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
|
||||
})
|
||||
.regex(/^\S+$/, {
|
||||
message: 'Password must not contain whitespace.',
|
||||
})
|
||||
.regex(/[0-9]/, {
|
||||
message: 'Password must contain at least one digit.',
|
||||
})
|
||||
.regex(/[a-z]/, {
|
||||
message: 'Password must contain at least one lowercase letter.',
|
||||
})
|
||||
.regex(/[A-Z]/, {
|
||||
message: 'Password must contain at least one uppercase letter.',
|
||||
})
|
||||
.regex(/[\p{P}\p{S}]/u, {
|
||||
message: 'Password must contain at least one symbol.',
|
||||
}),
|
||||
confirmPassword: z.string().min(PASSWORD_MIN, {
|
||||
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||
}),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwords do not match!',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const { signIn } = useAuthActions();
|
||||
const [flow, setFlow] = useState<'reset' | 'reset-verification'>('reset');
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [code, setCode] = useState<string>('');
|
||||
const router = useRouter();
|
||||
|
||||
const forgotPasswordForm = useForm<z.infer<typeof forgotPasswordSchema>>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
defaultValues: { email },
|
||||
});
|
||||
|
||||
const resetVerificationForm = useForm<
|
||||
z.infer<typeof resetVerificationSchema>
|
||||
>({
|
||||
resolver: zodResolver(resetVerificationSchema),
|
||||
defaultValues: { code, newPassword: '', confirmPassword: '' },
|
||||
});
|
||||
|
||||
const handleForgotPasswordSubmit = async (
|
||||
values: z.infer<typeof forgotPasswordSchema>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
formData.append('flow', flow);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => {
|
||||
setEmail(values.email);
|
||||
setFlow('reset-verification');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error resetting password: ', error);
|
||||
toast.error('Error resetting password.');
|
||||
} finally {
|
||||
forgotPasswordForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetVerificationSubmit = async (
|
||||
values: z.infer<typeof resetVerificationSchema>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('code', code);
|
||||
formData.append('newPassword', values.newPassword);
|
||||
formData.append('email', email);
|
||||
formData.append('flow', flow);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData);
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Error resetting password: ', error);
|
||||
toast.error('Error resetting password.');
|
||||
} finally {
|
||||
resetVerificationForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[400px] w-sm lg:w-md'>
|
||||
<CardHeader className='flex flex-col gap-4 items-center'>
|
||||
{flow === 'reset' ? (
|
||||
<>
|
||||
<CardTitle className='font-bold text-2xl'>
|
||||
Forgot Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email address and we will send you a link to reset
|
||||
your password.
|
||||
</CardDescription>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CardTitle className='font-bold text-2xl'>
|
||||
Reset Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your code and new password and we will reset your
|
||||
password.
|
||||
</CardDescription>
|
||||
</>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Card className='bg-card/50'>
|
||||
<CardContent>
|
||||
{flow === 'reset' ? (
|
||||
<Form {...forgotPasswordForm}>
|
||||
<form
|
||||
onSubmit={forgotPasswordForm.handleSubmit(
|
||||
handleForgotPasswordSubmit,
|
||||
)}
|
||||
className='flex flex-col space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={forgotPasswordForm.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Sending Email...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Send Email
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...resetVerificationForm}>
|
||||
<form
|
||||
onSubmit={resetVerificationForm.handleSubmit(
|
||||
handleResetVerificationSubmit,
|
||||
)}
|
||||
className='flex flex-col space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={resetVerificationForm.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
value={code}
|
||||
onChange={(value) => setCode(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSeparator />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please enter the one-time password sent to your
|
||||
phone.
|
||||
</FormDescription>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={resetVerificationForm.control}
|
||||
name='newPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
New Password
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={resetVerificationForm.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
Confirm Passsword
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Confirm your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Resetting Password...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Reset Password
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ForgotPassword;
|
||||
@@ -18,7 +18,8 @@ const Profile = async () => {
|
||||
<AvatarUpload preloadedUser={preloadedUser} />
|
||||
<Separator />
|
||||
<UserInfoForm preloadedUser={preloadedUser} />
|
||||
<ResetPasswordForm preloadedUser={preloadedUser} />
|
||||
<Separator />
|
||||
<ResetPasswordForm />
|
||||
<Separator />
|
||||
<SignOutForm />
|
||||
</Card>
|
||||
|
||||
@@ -12,17 +12,11 @@ import {
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -30,10 +24,6 @@ import {
|
||||
TabsTrigger,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
GibsAuthSignInButton,
|
||||
MicrosoftSignInButton,
|
||||
} from '@/components/layout/auth/buttons';
|
||||
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types';
|
||||
|
||||
const signInFormSchema = z.object({
|
||||
@@ -50,7 +40,7 @@ const signUpFormSchema = z
|
||||
name: z.string().min(2, {
|
||||
message: 'Name must be at least 2 characters.',
|
||||
}),
|
||||
email: z.email({
|
||||
email: z.string().email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
password: z
|
||||
@@ -85,17 +75,9 @@ const signUpFormSchema = z
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
const verifyEmailFormSchema = z.object({
|
||||
code: z.string({ message: 'Invalid code.' }),
|
||||
});
|
||||
|
||||
const SignIn = () => {
|
||||
const { signIn } = useAuthActions();
|
||||
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
|
||||
'signIn',
|
||||
);
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
@@ -108,17 +90,12 @@ const SignIn = () => {
|
||||
resolver: zodResolver(signUpFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email,
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
|
||||
resolver: zodResolver(verifyEmailFormSchema),
|
||||
defaultValues: { code },
|
||||
});
|
||||
|
||||
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
@@ -126,12 +103,13 @@ const SignIn = () => {
|
||||
formData.append('flow', flow);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/'));
|
||||
await signIn('password', formData);
|
||||
signInForm.reset();
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
toast.error('Error signing in.');
|
||||
} finally {
|
||||
signInForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -146,107 +124,17 @@ const SignIn = () => {
|
||||
try {
|
||||
if (values.confirmPassword !== values.password)
|
||||
throw new ConvexError('Passwords do not match.');
|
||||
await signIn('password', formData).then(() => {
|
||||
setEmail(values.email);
|
||||
setFlow('email-verification');
|
||||
});
|
||||
await signIn('password', formData);
|
||||
signUpForm.reset();
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
toast.error('Error signing up.');
|
||||
} finally {
|
||||
signUpForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmail = async (
|
||||
values: z.infer<typeof verifyEmailFormSchema>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('code', code);
|
||||
formData.append('flow', flow);
|
||||
formData.append('email', email);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/'));
|
||||
} catch (error) {
|
||||
console.error('Error verifying email:', error);
|
||||
toast.error('Error verifying email.');
|
||||
} finally {
|
||||
verifyEmailForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (flow === 'email-verification') {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
<CardContent>
|
||||
<div className='text-center mb-6'>
|
||||
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
|
||||
<p className='text-muted-foreground'>We sent a code to {email}</p>
|
||||
</div>
|
||||
<Form {...verifyEmailForm}>
|
||||
<form
|
||||
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={verifyEmailForm.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(value) => setCode(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSeparator />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please enter the one-time password sent to your email.
|
||||
</FormDescription>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Verify Email
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='text-center mt-4'>
|
||||
<button
|
||||
onClick={() => setFlow('signUp')}
|
||||
className='text-sm text-muted-foreground hover:underline'
|
||||
>
|
||||
Back to Sign Up
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
@@ -323,28 +211,12 @@ const SignIn = () => {
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing in...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
className='text-lg font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex justify-center'>
|
||||
<div
|
||||
className='flex flex-row items-center
|
||||
my-2.5 mx-auto justify-center w-1/4'
|
||||
>
|
||||
<Separator className='py-0.5 mr-3' />
|
||||
<span className='font-semibold text-lg'>or</span>
|
||||
<Separator className='py-0.5 ml-3' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center mb-3'>
|
||||
<MicrosoftSignInButton />
|
||||
</div>
|
||||
<div className='flex justify-center mt-3'>
|
||||
<GibsAuthSignInButton />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -437,25 +309,12 @@ const SignIn = () => {
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
className='text-lg font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Sign Up
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex my-auto justify-center w-2/3'>
|
||||
<div className='flex flex-row w-1/3 items-center my-2.5'>
|
||||
<Separator className='py-0.5 mr-3' />
|
||||
<span className='font-semibold text-lg'>or</span>
|
||||
<Separator className='py-0.5 ml-3' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center mb-3'>
|
||||
<MicrosoftSignInButton type='signUp' />
|
||||
</div>
|
||||
<div className='flex justify-center mt-3'>
|
||||
<GibsAuthSignInButton type='signUp' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
@@ -38,36 +38,38 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
return (
|
||||
<PlausibleProvider
|
||||
domain='techtracker.gbrown.org'
|
||||
customDomain='https://plausible.gbrown.org'
|
||||
>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
<ConvexClientProvider>
|
||||
<PlausibleProvider
|
||||
domain='techtracker.gbrown.org'
|
||||
customDomain='https://plausible.gbrown.org'
|
||||
>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ConvexClientProvider>
|
||||
<TVModeProvider>
|
||||
<Header />
|
||||
<main className='min-h-[90vh] flex flex-col items-center'>
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
)}
|
||||
<Toaster />
|
||||
</main>
|
||||
</TVModeProvider>
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</PlausibleProvider>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ConvexClientProvider>
|
||||
<TVModeProvider>
|
||||
<Header />
|
||||
<main className='min-h-[90vh] flex flex-col items-center'>
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
)}
|
||||
<Toaster />
|
||||
</main>
|
||||
</TVModeProvider>
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</PlausibleProvider>
|
||||
</ConvexClientProvider>
|
||||
);
|
||||
};
|
||||
export default GlobalError;
|
||||
|
||||
@@ -4,8 +4,6 @@ import '@/styles/globals.css';
|
||||
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
|
||||
import {
|
||||
ConvexClientProvider,
|
||||
LunchReminder,
|
||||
NotificationsPermission,
|
||||
ThemeProvider,
|
||||
TVModeProvider,
|
||||
} from '@/components/providers';
|
||||
@@ -24,11 +22,11 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
export const metadata: Metadata = generateMetadata();
|
||||
|
||||
const RootLayout = async ({
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
}>) {
|
||||
return (
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<PlausibleProvider
|
||||
@@ -50,8 +48,6 @@ const RootLayout = async ({
|
||||
<Header />
|
||||
{children}
|
||||
<Toaster />
|
||||
<NotificationsPermission />
|
||||
<LunchReminder />
|
||||
</TVModeProvider>
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
@@ -60,5 +56,4 @@ const RootLayout = async ({
|
||||
</PlausibleProvider>
|
||||
</ConvexAuthNextjsServerProvider>
|
||||
);
|
||||
};
|
||||
export default RootLayout;
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { Button, type buttonVariants } from '@/components/ui';
|
||||
import { type ComponentProps } from 'react';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
|
||||
type Props = {
|
||||
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
type?: 'signIn' | 'signUp';
|
||||
};
|
||||
|
||||
export const GibsAuthSignInButton = ({
|
||||
buttonProps,
|
||||
type = 'signIn',
|
||||
}: Props) => {
|
||||
const { signIn } = useAuthActions();
|
||||
return (
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => signIn('authentik')}
|
||||
className='text-lg font-semibold'
|
||||
{...buttonProps}
|
||||
>
|
||||
<div className='flex flex-row my-auto space-x-1'>
|
||||
<Image
|
||||
src={'/icons/misc/gibs-auth-logo.png'}
|
||||
className=''
|
||||
alt="Gib's Auth"
|
||||
width={30}
|
||||
height={30}
|
||||
/>
|
||||
<p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with Gib's Auth</p>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { GibsAuthSignInButton } from './gibs-auth';
|
||||
export { MicrosoftSignInButton } from './microsoft';
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { Button, type buttonVariants } from '@/components/ui';
|
||||
import { type ComponentProps } from 'react';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
|
||||
type Props = {
|
||||
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
type?: 'signIn' | 'signUp';
|
||||
};
|
||||
|
||||
export const MicrosoftSignInButton = ({
|
||||
buttonProps,
|
||||
type = 'signIn',
|
||||
}: Props) => {
|
||||
const { signIn } = useAuthActions();
|
||||
return (
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => signIn('microsoft-entra-id')}
|
||||
className='text-lg font-semibold'
|
||||
{...buttonProps}
|
||||
>
|
||||
<div className='flex flex-row my-auto space-x-2'>
|
||||
<div className='flex flex-row my-auto'>
|
||||
<svg className='scale-150' viewBox='0 0 23 23'>
|
||||
<path fill='#f35325' d='M1 1h10v10H1z' />
|
||||
<path fill='#81bc06' d='M12 1h10v10H12z' />
|
||||
<path fill='#05a6f0' d='M1 12h10v10H1z' />
|
||||
<path fill='#ffba08' d='M12 12h10v10H12z' />
|
||||
</svg>
|
||||
</div>
|
||||
<p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with Microsoft</p>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { type ChangeEvent, useRef, useState } from 'react';
|
||||
import {
|
||||
type Preloaded,
|
||||
@@ -9,14 +10,13 @@ import {
|
||||
} from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
BasedAvatar,
|
||||
Button,
|
||||
CardContent,
|
||||
ImageCrop,
|
||||
ImageCropApply,
|
||||
ImageCropContent,
|
||||
ImageCropReset,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
@@ -48,7 +48,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
const updateUserImage = useMutation(api.auth.updateUserImage);
|
||||
|
||||
const currentImageUrl = useQuery(
|
||||
api.files.getImageUrl,
|
||||
@@ -97,7 +97,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
storageId: Id<'_storage'>;
|
||||
};
|
||||
|
||||
await updateUser({ image: uploadResponse.storageId });
|
||||
await updateUserImage({ storageId: uploadResponse.storageId });
|
||||
|
||||
toast.success('Profile picture updated.');
|
||||
handleReset();
|
||||
@@ -121,7 +121,8 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
<BasedAvatar
|
||||
src={currentImageUrl ?? undefined}
|
||||
fullName={user?.name}
|
||||
className='h-42 w-42 text-6xl font-semibold'
|
||||
className='h-32 w-32'
|
||||
fallbackProps={{ className: 'text-4xl font-semibold' }}
|
||||
userIconProps={{ size: 100 }}
|
||||
/>
|
||||
<div
|
||||
@@ -172,6 +173,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
<ImageCropContent className='max-w-sm' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<ImageCropApply />
|
||||
<ImageCropReset />
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
size='icon'
|
||||
@@ -188,14 +190,19 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
{/* Cropped preview + actions */}
|
||||
{croppedImage && (
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<Avatar className='h-42 w-42'>
|
||||
<AvatarImage alt='Cropped preview' src={croppedImage} />
|
||||
</Avatar>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Image
|
||||
alt='Cropped preview'
|
||||
className='overflow-hidden rounded-full'
|
||||
height={128}
|
||||
src={croppedImage}
|
||||
unoptimized
|
||||
width={128}
|
||||
/>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isUploading}
|
||||
className='px-4'
|
||||
className='px-6'
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className='inline-flex items-center gap-2'>
|
||||
@@ -210,10 +217,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
onClick={handleReset}
|
||||
size='icon'
|
||||
type='button'
|
||||
className='dark:bg-red-500/30 bg-red-400/80
|
||||
hover:dark:text-red-300/60 hover:text-red-800/80
|
||||
hover:dark:bg-accent'
|
||||
variant='secondary'
|
||||
variant='ghost'
|
||||
>
|
||||
<XIcon className='size-4' />
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useState } from 'react';
|
||||
import { useAction } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import { z } from 'zod';
|
||||
import { type Preloaded, usePreloadedQuery } from 'convex/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
@@ -64,12 +62,7 @@ const formSchema = z
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type ResetFormProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
};
|
||||
|
||||
export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
export const ResetPasswordForm = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const changePassword = useAction(api.auth.updateUserPassword);
|
||||
@@ -101,12 +94,10 @@ export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
return user?.provider !== 'password' ? (
|
||||
<div />
|
||||
) : (
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<CardHeader>
|
||||
<CardHeader className='pb-5'>
|
||||
<CardTitle className='text-2xl'>Change Password</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
import { z } from 'zod';
|
||||
@@ -7,9 +7,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
@@ -19,7 +16,6 @@ import {
|
||||
FormMessage,
|
||||
Input,
|
||||
SubmitButton,
|
||||
Switch,
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -36,11 +32,6 @@ const formSchema = z.object({
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
lunchTime: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, { message: 'Must be a valid 24-hour time. Example: 13:00' }),
|
||||
automaticLunch: z.boolean(),
|
||||
});
|
||||
|
||||
type UserInfoFormProps = {
|
||||
@@ -51,47 +42,28 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const initialValues = useMemo<z.infer<typeof formSchema>>(
|
||||
() => ({
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
lunchTime: user?.lunchTime ?? '12:00',
|
||||
automaticLunch: user?.automaticLunch ?? false,
|
||||
}),
|
||||
[user?.name, user?.email, user?.lunchTime, user?.automaticLunch],
|
||||
);
|
||||
const updateUserName = useMutation(api.auth.updateUserName);
|
||||
const updateUserEmail = useMutation(api.auth.updateUserEmail);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: initialValues,
|
||||
defaultValues: {
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
const ops: Promise<unknown>[] = [];
|
||||
const name = values.name.trim();
|
||||
const email = values.email.trim().toLowerCase();
|
||||
const lunchTime = values.lunchTime.trim();
|
||||
const automaticLunch = values.automaticLunch;
|
||||
const patch: Partial<{
|
||||
name: string;
|
||||
email: string;
|
||||
lunchTime: string;
|
||||
automaticLunch: boolean;
|
||||
}> = {};
|
||||
if (name !== (user?.name ?? '') && name !== undefined)
|
||||
patch.name = name;
|
||||
if (email !== (user?.email ?? '') && email !== undefined)
|
||||
patch.email = email;
|
||||
if (lunchTime !== (user?.lunchTime && '') && lunchTime !== undefined)
|
||||
patch.lunchTime = lunchTime;
|
||||
if (automaticLunch !== user?.automaticLunch && automaticLunch !== undefined)
|
||||
patch.automaticLunch = automaticLunch;
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
if (name !== (user?.name ?? '')) ops.push(updateUserName({ name }));
|
||||
if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email }));
|
||||
if (ops.length === 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateUser(patch);
|
||||
form.reset(patch);
|
||||
await Promise.all(ops);
|
||||
form.reset({ name, email });
|
||||
toast.success('Profile updated successfully.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -102,107 +74,48 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-2xl'>Account Information</CardTitle>
|
||||
<CardDescription>Update your account information here.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-6'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={user?.provider !== 'password'}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your email address associated with your account.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='flex flex-row justify-center space-x-10'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='lunchTime'
|
||||
render={({ field }) => (
|
||||
<FormItem className='sm:w-2/5'>
|
||||
<div className='flex flex-row space-x-2 my-auto'>
|
||||
<FormLabel>Lunch Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='time' className='w-28' {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormDescription>Your regular lunch time.</FormDescription>
|
||||
<FormDescription className='dark:text-red-300/60 text-red-800/80'>
|
||||
{!user?.lunchTime && 'Not currently set.'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='automaticLunch'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-2/5 mt-2'>
|
||||
<div className='flex flex-row space-x-2 my-auto'>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className='border-solid border-primary'
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>Automatic Lunch</FormLabel>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Automatically take your lunch at the time you specify.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your email address associated with your account.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex justify-center mt-5'>
|
||||
<SubmitButton
|
||||
className='lg:w-1/3 w-2/3 text-[1.0rem]'
|
||||
disabled={loading}
|
||||
pendingText='Saving...'
|
||||
>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
<div className='flex justify-center'>
|
||||
<SubmitButton disabled={loading} pendingText='Saving...'>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
Input,
|
||||
Label,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -51,7 +49,6 @@ export const StatusList = ({
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [statusInput, setStatusInput] = useState('');
|
||||
const [persistStatus, setPersistStatus] = useState(false);
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set());
|
||||
const [previousStatuses, setPreviousStatuses] = useState(statuses);
|
||||
@@ -101,24 +98,11 @@ export const StatusList = ({
|
||||
throw new Error('Status must be between 3 & 80 characters');
|
||||
}
|
||||
if (selectedUserIds.length === 0 && user?.id) {
|
||||
await bulkCreate({
|
||||
message,
|
||||
userIds: [user.id],
|
||||
persistentStatus: persistStatus
|
||||
});
|
||||
await bulkCreate({ message, userIds: [user.id] });
|
||||
} else {
|
||||
await bulkCreate({
|
||||
message,
|
||||
userIds: selectedUserIds,
|
||||
persistentStatus: persistStatus
|
||||
});
|
||||
await bulkCreate({ message, userIds: selectedUserIds });
|
||||
}
|
||||
toast.success('Status updated.');
|
||||
toast.success('Status updated.', {
|
||||
duration: 2000,
|
||||
closeButton: true,
|
||||
dismissible: true,
|
||||
});
|
||||
setSelectedUserIds([]);
|
||||
setSelectAll(false);
|
||||
setStatusInput('');
|
||||
@@ -140,9 +124,9 @@ export const StatusList = ({
|
||||
|
||||
const containerCn = ccn({
|
||||
context: tvMode,
|
||||
className: 'mx-auto',
|
||||
className: 'max-w-4xl mx-auto',
|
||||
on: 'px-6',
|
||||
off: 'max-w-4xl px-4 sm:px-6',
|
||||
off: 'px-4 sm:px-6',
|
||||
});
|
||||
|
||||
const tabsCn = ccn({
|
||||
@@ -165,13 +149,13 @@ export const StatusList = ({
|
||||
<TabsList className={tabsCn}>
|
||||
<TabsTrigger value='status' className='py-3 sm:py-8'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<Activity className='text-primary sm:scale-150' />
|
||||
<h1 className='text-base sm:text-2xl font-bold'>Status List</h1>
|
||||
<Activity className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
|
||||
<h1 className='text-base sm:text-2xl font-bold'>Team Status</h1>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='history' className='py-3 sm:py-8'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<History className='text-primary sm:scale-150' />
|
||||
<History className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
|
||||
<h1 className='text-base sm:text-2xl font-bold'>
|
||||
Status History
|
||||
</h1>
|
||||
@@ -201,26 +185,22 @@ export const StatusList = ({
|
||||
|
||||
{/* Desktop header */}
|
||||
<div className={headerCn}>
|
||||
<div className='flex w-full justify-between px-4'>
|
||||
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Users className='sm:w-4 sm:h-4 w-3 h-3' />
|
||||
<span>{statuses.length} members</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Users className='sm:w-4 sm:h-4 w-3 h-3' />
|
||||
<span>{statuses.length} members</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-4 text-xs sm:text-base'>
|
||||
<div className='flex items-center gap-2 text-xs'>
|
||||
<Link href='/table' className='font-medium hover:underline'>
|
||||
Miss the old table?
|
||||
</Link>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-xs'>
|
||||
<Link href='/table' className='font-medium hover:underline'>
|
||||
Miss the old table?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card list */}
|
||||
<div className='space-y-2 sm:space-y-3 pb-24 sm:pb-0'>
|
||||
{previousStatuses.map((statusData) => {
|
||||
{statuses.map((statusData) => {
|
||||
const { user: u, status: s } = statusData;
|
||||
const isSelected = selectedUserIds.includes(u.id);
|
||||
const isAnimating = animatingIds.has(u.id);
|
||||
@@ -232,7 +212,6 @@ export const StatusList = ({
|
||||
className={`
|
||||
relative rounded-xl border transition-all
|
||||
${isAnimating ? 'bg-primary/5 border-primary/30' : ''}
|
||||
${s?.persistentStatus ? 'bg-black/10' : ''}
|
||||
${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
@@ -253,46 +232,43 @@ export const StatusList = ({
|
||||
|
||||
<div className='flex items-start gap-3 sm:gap-4'>
|
||||
{/* Avatar */}
|
||||
<div className='shrink-0'>
|
||||
<div className='flex-shrink-0'>
|
||||
<BasedAvatar
|
||||
src={u.imageUrl}
|
||||
fullName={u.name ?? 'User'}
|
||||
className={`
|
||||
transition-all duration-300
|
||||
${tvMode ? 'w-36 h-36 text-4xl' : 'w-10 h-10 sm:w-12 sm:h-12'}
|
||||
${tvMode ? 'w-18 h-18' : 'w-10 h-10 sm:w-12 sm:h-12'}
|
||||
${isAnimating ? 'ring-primary/30 ring-4' : ''}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2 sm:gap-3 mb-1'>
|
||||
<h3
|
||||
className={`font-semibold
|
||||
${tvMode ? 'text-5xl' : 'text-base sm:text-xl'}
|
||||
className={`
|
||||
font-semibold truncate
|
||||
${tvMode ? 'text-3xl' : 'text-base sm:text-xl'}
|
||||
`}
|
||||
title={u.name ?? u.email ?? 'User'}
|
||||
>
|
||||
{u.name ?? u.email ?? 'User'}
|
||||
</h3>
|
||||
|
||||
{isUpdatedByOther && s?.updatedBy && (
|
||||
<div
|
||||
className='hidden sm:flex items-center gap-2
|
||||
text-muted-foreground min-w-0'
|
||||
>
|
||||
<span
|
||||
className={`${tvMode ? 'text-3xl' : 'text-sm'}`}
|
||||
>
|
||||
via
|
||||
</span>
|
||||
<span className='text-sm'>via</span>
|
||||
<BasedAvatar
|
||||
src={s.updatedBy.imageUrl}
|
||||
fullName={s.updatedBy.name ?? 'User'}
|
||||
className={`${tvMode ? 'w-14 h-14 text-xl' : 'w-6 h-6'}`}
|
||||
className='w-4 h-4'
|
||||
/>
|
||||
<span
|
||||
className={`${tvMode ? 'text-4xl' : 'truncate'}`}
|
||||
>
|
||||
<span className='text-sm truncate'>
|
||||
{s.updatedBy.name ??
|
||||
s.updatedBy.email ??
|
||||
'another user'}
|
||||
@@ -300,67 +276,51 @@ export const StatusList = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`
|
||||
mb-2 sm:mb-3
|
||||
${tvMode ? 'text-6xl' : 'text-[0.95rem] sm:text-lg'}
|
||||
${s ? 'text-foreground' : 'text-muted-foreground italic'}
|
||||
mb-2 sm:mb-3 leading-relaxed break-words
|
||||
${tvMode ? 'text-2xl' : 'text-[0.95rem] sm:text-lg'}
|
||||
${
|
||||
s
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground italic'
|
||||
}
|
||||
line-clamp-2
|
||||
`}
|
||||
title={s?.message ?? undefined}
|
||||
>
|
||||
{s?.message ?? 'No status yet.'}
|
||||
</div>
|
||||
{/* Meta - only show here when NOT in TV mode */}
|
||||
{!tvMode && (
|
||||
<div className='flex items-center text-muted-foreground gap-3 sm:gap-4'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Clock className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-sm sm:text-lg'>
|
||||
{s ? formatTime(s.updatedAt) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Calendar className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-sm sm:text-lg'>
|
||||
{s ? formatDate(s.updatedAt) : '--/--'}
|
||||
</span>
|
||||
</div>
|
||||
{s && (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Activity className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-sm sm:text-lg'>
|
||||
{getStatusAge(s.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Date/Time Column - only show when in TV mode */}
|
||||
{tvMode && (
|
||||
<div className='flex flex-col items-end gap-2 text-muted-foreground min-w-0'>
|
||||
|
||||
{/* Meta */}
|
||||
<div
|
||||
className='flex items-center gap-3 sm:gap-4
|
||||
text-muted-foreground'
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Clock className='w-8 h-8' />
|
||||
<span className='text-4xl'>
|
||||
<Clock className='w-4 h-4 sm:w-4 sm:h-4' />
|
||||
<span className='text-xs sm:text-sm'>
|
||||
{s ? formatTime(s.updatedAt) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Calendar className='w-8 h-8' />
|
||||
<span className='text-4xl'>
|
||||
<div className='hidden xs:flex items-center gap-1.5'>
|
||||
<Calendar className='w-4 h-4' />
|
||||
<span className='text-xs sm:text-sm'>
|
||||
{s ? formatDate(s.updatedAt) : '--/--'}
|
||||
</span>
|
||||
</div>
|
||||
{s && (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Activity className='w-8 h-8' />
|
||||
<span className='text-4xl'>
|
||||
<Activity className='w-4 h-4' />
|
||||
<span className='text-xs sm:text-sm'>
|
||||
{getStatusAge(s.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!tvMode && (
|
||||
<div className='flex flex-col items-end gap-2'>
|
||||
@@ -381,6 +341,7 @@ export const StatusList = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile "via user" line */}
|
||||
{isUpdatedByOther && s?.updatedBy && (
|
||||
<div
|
||||
@@ -414,29 +375,17 @@ export const StatusList = ({
|
||||
>
|
||||
<CardContent className='p-6'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex gap-3 w-full justify-between'>
|
||||
<div className='flex gap-3 items-center'>
|
||||
<Zap className='w-6 h-6 text-primary' />
|
||||
<h3 className='text-xl font-semibold'>Update Status</h3>
|
||||
{selectedUserIds.length > 0 && (
|
||||
<span
|
||||
className='px-2 py-1 bg-primary/10 text-primary
|
||||
text-sm rounded-full'
|
||||
>
|
||||
{selectedUserIds.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex space-x-2 items-center'>
|
||||
<Checkbox
|
||||
checked={persistStatus}
|
||||
className='border border-primary'
|
||||
onCheckedChange={() => setPersistStatus(!persistStatus)}
|
||||
/>
|
||||
<Label>
|
||||
Persist Status
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Zap className='w-5 h-5 text-primary' />
|
||||
<h3 className='text-lg font-semibold'>Update Status</h3>
|
||||
{selectedUserIds.length > 0 && (
|
||||
<span
|
||||
className='px-2 py-1 bg-primary/10 text-primary
|
||||
text-sm rounded-full'
|
||||
>
|
||||
{selectedUserIds.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-3'>
|
||||
<Input
|
||||
@@ -494,7 +443,7 @@ export const StatusList = ({
|
||||
<div
|
||||
className='md:hidden fixed bottom-0 left-0 right-0 z-50
|
||||
border-t bg-background/95 backdrop-blur
|
||||
supports-backdrop-filter:bg-background/60 p-3
|
||||
supports-[backdrop-filter]:bg-background/60 p-3
|
||||
pb-[calc(0.75rem+env(safe-area-inset-bottom))]'
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
@@ -507,16 +456,6 @@ export const StatusList = ({
|
||||
Update your status
|
||||
</span>
|
||||
)}
|
||||
<div className='flex flex-row space-x-2'>
|
||||
<Checkbox
|
||||
className='border border-primary'
|
||||
checked={persistStatus}
|
||||
onCheckedChange={() => setPersistStatus(!persistStatus)}
|
||||
/>
|
||||
<Label className='text-xs'>
|
||||
Persist Status
|
||||
</Label>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' onClick={handleSelectAll}>
|
||||
{selectAll ? 'Clear' : 'Select all'}
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useTVMode } from '@/components/providers';
|
||||
import {
|
||||
BasedAvatar,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
Input,
|
||||
@@ -15,7 +17,7 @@ import {
|
||||
} from '@/components/ui';
|
||||
import { toast } from 'sonner';
|
||||
import { ccn, formatTime, formatDate } from '@/lib/utils';
|
||||
import { Clock, Calendar } from 'lucide-react';
|
||||
import { Clock, Calendar, CheckCircle2 } from 'lucide-react';
|
||||
import { StatusHistory } from '@/components/layout/status';
|
||||
|
||||
type StatusTableProps = {
|
||||
@@ -118,7 +120,7 @@ export const StatusTable = ({
|
||||
</div>
|
||||
<table className='w-full text-center rounded-md'>
|
||||
<thead>
|
||||
<tr className='dark:bg-muted bg-accent/30'>
|
||||
<tr className='bg-muted'>
|
||||
{!tvMode && (
|
||||
<th className={tCheckboxCn}>
|
||||
<input
|
||||
@@ -149,11 +151,7 @@ export const StatusTable = ({
|
||||
<tr
|
||||
key={u.id}
|
||||
className={`
|
||||
${
|
||||
i % 2 === 0
|
||||
? 'dark:bg-muted/20 bg-muted'
|
||||
: 'dark:bg-muted/80 bg-accent/50'
|
||||
}
|
||||
${i % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
hover:bg-muted/75 transition-all duration-300
|
||||
`}
|
||||
@@ -173,7 +171,7 @@ export const StatusTable = ({
|
||||
<BasedAvatar
|
||||
src={u.imageUrl}
|
||||
fullName={u.name}
|
||||
className={tvMode ? 'w-16 h-16 text-2xl' : 'w-12 h-12'}
|
||||
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
||||
/>
|
||||
<div>
|
||||
<p> {u.name ?? 'Technician #' + (i + 1)} </p>
|
||||
@@ -182,7 +180,7 @@ export const StatusTable = ({
|
||||
<BasedAvatar
|
||||
src={s.updatedBy.imageUrl}
|
||||
fullName={s.updatedBy.name}
|
||||
className='w-5 h-5 text-xs'
|
||||
className='w-5 h-5'
|
||||
/>
|
||||
<span className={tvMode ? 'text-xl' : 'text-base'}>
|
||||
Updated by {s.updatedBy.name}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
export { ConvexClientProvider } from './ConvexClientProvider';
|
||||
export { LunchReminder } from './lunch-reminder';
|
||||
export { NotificationsPermission } from './notification-permission';
|
||||
export {
|
||||
ThemeProvider,
|
||||
ThemeToggle,
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
|
||||
const nextOccurrenceMs = (hhmm: string, from = new Date()): number => {
|
||||
const [hStr, mStr] = hhmm.split(':');
|
||||
const target = new Date(from);
|
||||
target.setHours(Number(hStr), Number(mStr), 0, 0);
|
||||
if (target <= from) target.setDate(target.getDate() + 1);
|
||||
return target.getTime() - from.getTime();
|
||||
};
|
||||
|
||||
export const LunchReminder = () => {
|
||||
const setStatus = useMutation(api.statuses.createLunchStatus);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const user = useQuery(api.auth.getUser, {});
|
||||
const lunchTime = user?.lunchTime ?? '';
|
||||
const automaticLunch = user?.automaticLunch ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (!lunchTime || automaticLunch) {
|
||||
return;
|
||||
}
|
||||
const schedule = () => {
|
||||
const ms = nextOccurrenceMs(lunchTime);
|
||||
console.log('Ms = ', ms);
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (
|
||||
'Notification' in window &&
|
||||
Notification.permission === 'granted'
|
||||
) {
|
||||
new Notification('Lunch time?', {
|
||||
body: 'Update your status to "At lunch"?',
|
||||
tag: 'tech-tracker-lunch',
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
toast('Lunch time?', {
|
||||
description: 'Would you like to set your status to "At lunch"?',
|
||||
action: {
|
||||
label: 'Set to lunch',
|
||||
onClick: () => void setStatus({}),
|
||||
},
|
||||
cancel: {
|
||||
label: 'Not now',
|
||||
onClick: () => console.log('User declined lunch suggestion'),
|
||||
},
|
||||
id: 'lunch-reminder',
|
||||
});
|
||||
schedule();
|
||||
})();
|
||||
}, ms);
|
||||
};
|
||||
|
||||
schedule();
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null
|
||||
}
|
||||
};
|
||||
}, [automaticLunch, lunchTime, setStatus]);
|
||||
return null;
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const STORAGE_KEY = 'notif.permission.prompted.v1';
|
||||
|
||||
export const NotificationsPermission = () => {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('Notification' in window)) return;
|
||||
|
||||
// Only ask once; tweak logic to your taste.
|
||||
const prompted = localStorage.getItem(STORAGE_KEY) === '1';
|
||||
console.log('NotificationsPermission', {
|
||||
supported: true,
|
||||
permission: Notification.permission,
|
||||
prompted,
|
||||
});
|
||||
if (prompted) return;
|
||||
|
||||
if (Notification.permission === 'default') {
|
||||
toast('Enable system notifications?', {
|
||||
id: 'enable-notifications',
|
||||
description: 'Get a native notification at lunch time (optional).',
|
||||
action: {
|
||||
label: 'Enable',
|
||||
onClick: () => {
|
||||
// Must be called during the click handler.
|
||||
const p = Notification.requestPermission();
|
||||
p.then((perm) => {
|
||||
localStorage.setItem(STORAGE_KEY, '1');
|
||||
if (perm === 'granted') {
|
||||
toast.success('System notifications enabled');
|
||||
} else {
|
||||
toast('Okay, we will use in-app toasts instead.');
|
||||
}
|
||||
}).catch(() => {
|
||||
localStorage.setItem(STORAGE_KEY, '1');
|
||||
toast('Failed to request notification permission.');
|
||||
});
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: 'Not now',
|
||||
onClick: () => {
|
||||
localStorage.setItem(STORAGE_KEY, '1');
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot='checkbox'
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot='checkbox-indicator'
|
||||
className='flex items-center justify-center text-current transition-none'
|
||||
>
|
||||
<CheckIcon className='size-3.5' />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -11,7 +11,6 @@ export {
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from './card';
|
||||
export { Checkbox } from './checkbox';
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
@@ -55,12 +54,6 @@ export {
|
||||
ImageCropReset,
|
||||
} from './shadcn-io/image-crop';
|
||||
export { Input } from './input';
|
||||
export {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
} from './input-otp';
|
||||
export { Label } from './label';
|
||||
export {
|
||||
Pagination,
|
||||
@@ -76,7 +69,6 @@ export { ScrollArea, ScrollBar } from './scroll-area';
|
||||
export { Separator } from './separator';
|
||||
export { StatusMessage } from './status-message';
|
||||
export { SubmitButton } from './submit-button';
|
||||
export { Switch } from './switch';
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { MinusIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot='input-otp'
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-disabled:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='input-otp-group'
|
||||
className={cn('flex items-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot='input-otp-slot'
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
|
||||
<div className='animate-caret-blink bg-foreground h-4 w-px duration-1000' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div data-slot='input-otp-separator' role='separator' {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
@@ -1,31 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot='switch'
|
||||
className={cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot='switch-thumb'
|
||||
className={cn(
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
@@ -7,26 +7,20 @@ export const env = createEnv({
|
||||
.enum(['development', 'test', 'production'])
|
||||
.default('development'),
|
||||
SKIP_ENV_VALIDATION: z.boolean().default(false),
|
||||
SITE_URL: z.url().default('http://localhost:3000'),
|
||||
SENTRY_AUTH_TOKEN: z.string(),
|
||||
CI: z.boolean().default(true),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_CONVEX_URL: z.url(),
|
||||
NEXT_PUBLIC_SITE_URL: z.url().default('http://localhost:3000'),
|
||||
NEXT_PUBLIC_CONVEX_URL: z
|
||||
.url()
|
||||
.default('https://api.dev.convex.gbrown.org'),
|
||||
NEXT_PUBLIC_SENTRY_DSN: z
|
||||
.url()
|
||||
.default('https://96df775337cce23d925616dd5aea8857@sentry.gbrown.org/2'),
|
||||
NEXT_PUBLIC_SENTRY_URL: z.url().default('https://sentry.gbrown.org'),
|
||||
NEXT_PUBLIC_SENTRY_DSN: z.url(),
|
||||
NEXT_PUBLIC_SENTRY_URL: z.url(),
|
||||
NEXT_PUBLIC_SENTRY_ORG: z.string().default('gib'),
|
||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string().default('techtracker-next'),
|
||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
|
||||
SITE_URL: process.env.SITE_URL,
|
||||
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
||||
CI: process.env.CI,
|
||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||
|
||||
@@ -3,21 +3,14 @@ import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!,
|
||||
integrations: [
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: false,
|
||||
blockAllMedia: false,
|
||||
}),
|
||||
],
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
||||
sendDefaultPii: true,
|
||||
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
|
||||
tracesSampleRate: 1,
|
||||
enableLogs: true,
|
||||
tracesSampleRate: 1.0,
|
||||
integrations: [Sentry.replayIntegration()],
|
||||
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
||||
replaysSessionSampleRate: 0.5,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
debug: false,
|
||||
});
|
||||
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import type { Instrumentation } from 'next';
|
||||
|
||||
export const register = async () => await import('../sentry.server.config');
|
||||
export const register = async () => {
|
||||
await import('../sentry.server.config');
|
||||
};
|
||||
|
||||
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
||||
Sentry.captureRequestError(...args);
|
||||
};
|
||||
|
||||
@@ -51,8 +51,7 @@ export const formatDate = (timestamp: Timestamp, locale = 'en-US'): string => {
|
||||
const date = toDate(timestamp);
|
||||
if (!date) return '--/--';
|
||||
return date.toLocaleDateString(locale, {
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,19 +8,16 @@ import { banSuspiciousIPs } from '@/lib/middleware/ban-suspicious-ips';
|
||||
const isSignInPage = createRouteMatcher(['/signin']);
|
||||
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
|
||||
|
||||
export default convexAuthNextjsMiddleware(
|
||||
async (request, { convexAuth }) => {
|
||||
const banResponse = banSuspiciousIPs(request);
|
||||
if (banResponse) return banResponse;
|
||||
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/');
|
||||
}
|
||||
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/signin');
|
||||
}
|
||||
},
|
||||
{ cookieConfig: { maxAge: 60 * 60 * 24 * 30 } },
|
||||
);
|
||||
export default convexAuthNextjsMiddleware(async (request, { convexAuth }) => {
|
||||
const banResponse = banSuspiciousIPs(request);
|
||||
if (banResponse) return banResponse;
|
||||
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/');
|
||||
}
|
||||
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/signin');
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
// The following matcher runs middleware on all routes
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM oven/bun:alpine AS base
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY apps/next/package.json apps/next/bun.lockb* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY tsconfig.base.json /tsconfig.base.json
|
||||
COPY packages/backend ./packages/backend
|
||||
COPY apps/next ./
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN bun run build
|
||||
|
||||
#FROM base AS runner
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup -S nodejs -g 1001 && adduser -S nextjs -u 1001
|
||||
#RUN adduser --system --uid 1001 nextjs
|
||||
#RUN chown nextjs:bun .next
|
||||
|
||||
# Copy from the correct paths (builder has apps/next at root)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,70 +0,0 @@
|
||||
networks:
|
||||
nginx-bridge: # You need to change this line to your defined network is as well
|
||||
external: true
|
||||
|
||||
services:
|
||||
techtracker-next:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./docker/Dockerfile
|
||||
image: ${NEXT_CONTAINER_NAME}:alpine
|
||||
container_name: ${NEXT_CONTAINER_NAME}
|
||||
env_file: [.env]
|
||||
hostname: ${NEXT_CONTAINER_NAME}
|
||||
domainname: ${NEXT_DOMAIN_NAME}
|
||||
networks: ['${NETWORK:-nginx-bridge}']
|
||||
#ports: ['${NEXT_PORT}:3000']
|
||||
depends_on: ['convex-backend']
|
||||
tty: true
|
||||
stdin_open: true
|
||||
restart: unless-stopped
|
||||
|
||||
convex-backend:
|
||||
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-00bd92723422f3bff968230c94ccdeb8c1719832}
|
||||
container_name: ${BACKEND_CONTAINER_NAME:-convex-backend}
|
||||
hostname: ${BACKEND_CONTAINER_NAME:-convex-backend}
|
||||
domainname: ${BACKEND_DOMAIN_NAME:-convex.gbrown.org}
|
||||
networks: ['${NETWORK:-nginx-bridge}']
|
||||
#user: '1000:1000'
|
||||
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
|
||||
volumes: [./data:/convex/data]
|
||||
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||
env_file: ['.env']
|
||||
environment:
|
||||
- INSTANCE_NAME
|
||||
- INSTANCE_SECRET
|
||||
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${BACKEND_PORT:-3210}}
|
||||
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${SITE_PROXY_PORT:-3211}}
|
||||
- DISABLE_BEACON=${DISABLE_BEACON:-}
|
||||
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-}
|
||||
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-}
|
||||
#- DATABASE_URL=${DATABASE_URL:-}
|
||||
stdin_open: true
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: curl -f http://localhost:3210/version
|
||||
interval: 5s
|
||||
start_period: 10s
|
||||
stop_grace_period: 10s
|
||||
stop_signal: SIGINT
|
||||
convex-dashboard:
|
||||
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-33cef775a8a6228cbacee4a09ac2c4073d62ed13}
|
||||
container_name: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
|
||||
hostname: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
|
||||
domainname: ${DASHBOARD_DOMAIN_NAME:-dashboard.${BACKEND_DOMAIN_NAME:-convex.gbrown.org}}
|
||||
networks: ['${NETWORK:-nginx-bridge}']
|
||||
#user: 1000:1000
|
||||
#ports: ['${DASHBOARD_PORT:-6791}:6791']
|
||||
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||
env_file: [.env]
|
||||
environment:
|
||||
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${PORT:-3210}}
|
||||
depends_on:
|
||||
convex-backend:
|
||||
condition: service_healthy
|
||||
stdin_open: true
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 10s
|
||||
stop_signal: SIGINT
|
||||
@@ -1,35 +0,0 @@
|
||||
# Next Envrionment Variables
|
||||
NETWORK=nginx-bridge
|
||||
NEXT_CONTAINER_NAME=techtracker-next
|
||||
NEXT_DOMAIN_NAME=techtracker.gbrown.org
|
||||
# Port is disabled by default as suggested
|
||||
# config is to have reverse proxy on the same
|
||||
# network so you can just forward to the
|
||||
# port on the internal network.
|
||||
NEXT_PORT=3000
|
||||
|
||||
# Convex Environment Variables
|
||||
BACKEND_TAG=00bd92723422f3bff968230c94ccdeb8c1719832
|
||||
BACKEND_CONTAINER_NAME=tt-convex-backend
|
||||
BACKEND_DOMAIN_NAME=convex.gbrown.org
|
||||
#BACKEND_PORT=
|
||||
#SITE_PROXY_PORT=
|
||||
DASHBOARD_TAG=33cef775a8a6228cbacee4a09ac2c4073d62ed13
|
||||
DASHBOARD_CONTAINER_NAME=tt-convex-dashboard
|
||||
DASHBOARD_DOMAIN=dashboard.convex.gbrown.org
|
||||
#DASHBOARD_PORT
|
||||
INSTANCE_NAME=Convex.gib
|
||||
#INSTANCE_SECRET=
|
||||
CONVEX_CLOUD_ORIGIN=https://api.convex.gbrown.org
|
||||
CONVEX_SITE_ORIGIN=https://convex.gbrown.org
|
||||
DISABLE_BEACON=true
|
||||
REDACT_LOGS_TO_CLIENT=true
|
||||
DO_NOT_REQUIRE_SSL=true
|
||||
NEXT_PUBLIC_DEPLOYMENT_URL=https://api.convex.gbrown.org
|
||||
#POSTGRES_URL=
|
||||
#DATABASE_URL=
|
||||
#CONVEX_RELEASE_VERSION_DEV=
|
||||
#ACTIONS_USER_TIMEOUT_SECS=
|
||||
#MYSQL_URL=
|
||||
#RUST_LOG=
|
||||
#RUST_BACKTRACE=
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e # Exit immediately if a command exits with a non-zero status.
|
||||
|
||||
# --- Configuration ---
|
||||
COMPOSE_FILE="./docker/compose.yml"
|
||||
DEFAULT_PROJECT_NAME="techtracker"
|
||||
DEV_PROJECT_NAME="dev-techtracker" # The project name for dev mode
|
||||
|
||||
COMPOSE_PROJECT_FLAG=${DEFAULT_PROJECT_NAME} # This will hold "-p dev-techtracker" if --dev is used
|
||||
|
||||
# --- Function to display usage ---
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo "Or: ./update.sh [OPTIONS]" # Assuming the script is named update.sh
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -d, --dev Run in development mode, using project name '${DEV_PROJECT_NAME}'."
|
||||
echo " Adds '-p ${DEV_PROJECT_NAME}' to docker compose commands."
|
||||
echo " -h, --help Display this help message."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Parse arguments ---
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-d|--dev)
|
||||
COMPOSE_PROJECT_FLAG=${DEV_PROJECT_NAME}
|
||||
shift # Consume the argument
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown argument '$1'" >&2
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Main Script Logic ---
|
||||
echo "\n--- Pulling latest git changes ---\n"
|
||||
git pull
|
||||
echo "\n--- Building Docker Compose services ${COMPOSE_PROJECT_FLAG} ---\n"
|
||||
sudo docker compose -p ${COMPOSE_PROJECT_FLAG} -f "${COMPOSE_FILE}" build
|
||||
echo "\n--- Bringing down Docker Compose services ${COMPOSE_PROJECT_FLAG} ---\n"
|
||||
sudo docker compose -p ${COMPOSE_PROJECT_FLAG} -f "${COMPOSE_FILE}" down
|
||||
echo "\n--- Bringing up Docker Compose services ${COMPOSE_PROJECT_FLAG} in detached mode ---\n"
|
||||
sudo docker compose -p ${COMPOSE_PROJECT_FLAG} -f "${COMPOSE_FILE}" up -d
|
||||
echo "\n--- Script finished successfully ---\n"
|
||||
@@ -14,7 +14,10 @@ export const baseConfig = {
|
||||
'warn',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'@typescript-eslint/no-misused-promises': [
|
||||
'error',
|
||||
|
||||
11
package.json
11
package.json
@@ -8,23 +8,20 @@
|
||||
"packageManager": "bun@1.2.19",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev:tunnel": "turbo run dev:tunnel",
|
||||
"build": "turbo run build",
|
||||
"clean": "turbo run clean && rm -rf node_modules",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --cache"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.6.2",
|
||||
"turbo": "^2.5.8",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/node": "^20.19.23"
|
||||
"turbo": "^2.5.6",
|
||||
"eslint": "^9.35.0",
|
||||
"typescript": "^5.9.2",
|
||||
"@types/node": "^20.19.13"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@sentry/cli",
|
||||
"@tailwindcss/oxide",
|
||||
"esbuild",
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
|
||||
18
packages/backend/convex/CustomPassword.ts
Normal file
18
packages/backend/convex/CustomPassword.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ConvexError } from 'convex/values';
|
||||
import { Password } from '@convex-dev/auth/providers/Password';
|
||||
import { validatePassword } from './auth';
|
||||
import type { DataModel } from './_generated/dataModel';
|
||||
|
||||
export default Password<DataModel>({
|
||||
profile(params, ctx) {
|
||||
return {
|
||||
email: params.email as string,
|
||||
name: params.name as string,
|
||||
};
|
||||
},
|
||||
validatePasswordRequirements: (password: string) => {
|
||||
if (!validatePassword(password)) {
|
||||
throw new ConvexError('Invalid password.');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -7,8 +7,8 @@ A query function that takes two arguments looks like:
|
||||
|
||||
```ts
|
||||
// functions.js
|
||||
import { query } from './_generated/server';
|
||||
import { v } from 'convex/values';
|
||||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const myQueryFunction = query({
|
||||
// Validators for arguments.
|
||||
@@ -21,7 +21,7 @@ export const myQueryFunction = query({
|
||||
handler: async (ctx, args) => {
|
||||
// Read the database as many times as you need here.
|
||||
// See https://docs.convex.dev/database/reading-data.
|
||||
const documents = await ctx.db.query('tablename').collect();
|
||||
const documents = await ctx.db.query("tablename").collect();
|
||||
|
||||
// Arguments passed from the client are properties of the args object.
|
||||
console.log(args.first, args.second);
|
||||
@@ -38,7 +38,7 @@ Using this query function in a React component looks like:
|
||||
```ts
|
||||
const data = useQuery(api.functions.myQueryFunction, {
|
||||
first: 10,
|
||||
second: 'hello',
|
||||
second: "hello",
|
||||
});
|
||||
```
|
||||
|
||||
@@ -46,8 +46,8 @@ A mutation function looks like:
|
||||
|
||||
```ts
|
||||
// functions.js
|
||||
import { mutation } from './_generated/server';
|
||||
import { v } from 'convex/values';
|
||||
import { mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const myMutationFunction = mutation({
|
||||
// Validators for arguments.
|
||||
@@ -62,7 +62,7 @@ export const myMutationFunction = mutation({
|
||||
// Mutations can also read from the database like queries.
|
||||
// See https://docs.convex.dev/database/writing-data.
|
||||
const message = { body: args.first, author: args.second };
|
||||
const id = await ctx.db.insert('messages', message);
|
||||
const id = await ctx.db.insert("messages", message);
|
||||
|
||||
// Optionally, return a value from your mutation.
|
||||
return await ctx.db.get(id);
|
||||
@@ -76,10 +76,10 @@ Using this mutation function in a React component looks like:
|
||||
const mutation = useMutation(api.functions.myMutationFunction);
|
||||
function handleButtonPress() {
|
||||
// fire and forget, the most common way to use mutations
|
||||
mutation({ first: 'Hello!', second: 'me' });
|
||||
mutation({ first: "Hello!", second: "me" });
|
||||
// OR
|
||||
// use the result once the mutation has completed
|
||||
mutation({ first: 'Hello!', second: 'me' }).then((result) =>
|
||||
mutation({ first: "Hello!", second: "me" }).then((result) =>
|
||||
console.log(result),
|
||||
);
|
||||
}
|
||||
|
||||
29
packages/backend/convex/_generated/api.d.ts
vendored
29
packages/backend/convex/_generated/api.d.ts
vendored
@@ -8,21 +8,17 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as custom_auth_index from "../custom/auth/index.js";
|
||||
import type * as custom_auth_providers_entra from "../custom/auth/providers/entra.js";
|
||||
import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
|
||||
import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as statuses from "../statuses.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
FilterApi,
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
import type * as CustomPassword from "../CustomPassword.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as statuses from "../statuses.js";
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
@@ -33,25 +29,18 @@ import type {
|
||||
* ```
|
||||
*/
|
||||
declare const fullApi: ApiFromModules<{
|
||||
CustomPassword: typeof CustomPassword;
|
||||
auth: typeof auth;
|
||||
crons: typeof crons;
|
||||
"custom/auth/index": typeof custom_auth_index;
|
||||
"custom/auth/providers/entra": typeof custom_auth_providers_entra;
|
||||
"custom/auth/providers/password": typeof custom_auth_providers_password;
|
||||
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
|
||||
files: typeof files;
|
||||
http: typeof http;
|
||||
statuses: typeof statuses;
|
||||
}>;
|
||||
declare const fullApiWithMounts: typeof fullApi;
|
||||
|
||||
export declare const api: FilterApi<
|
||||
typeof fullApiWithMounts,
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "public">
|
||||
>;
|
||||
export declare const internal: FilterApi<
|
||||
typeof fullApiWithMounts,
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "internal">
|
||||
>;
|
||||
|
||||
export declare const components: {};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { anyApi, componentsGeneric } from "convex/server";
|
||||
import { anyApi } from "convex/server";
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
@@ -20,4 +20,3 @@ import { anyApi, componentsGeneric } from "convex/server";
|
||||
*/
|
||||
export const api = anyApi;
|
||||
export const internal = anyApi;
|
||||
export const components = componentsGeneric();
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
import {
|
||||
ActionBuilder,
|
||||
AnyComponents,
|
||||
HttpActionBuilder,
|
||||
MutationBuilder,
|
||||
QueryBuilder,
|
||||
@@ -19,15 +18,9 @@ import {
|
||||
GenericQueryCtx,
|
||||
GenericDatabaseReader,
|
||||
GenericDatabaseWriter,
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
import type { DataModel } from "./dataModel.js";
|
||||
|
||||
type GenericCtx =
|
||||
| GenericActionCtx<DataModel>
|
||||
| GenericMutationCtx<DataModel>
|
||||
| GenericQueryCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
*
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
internalActionGeneric,
|
||||
internalMutationGeneric,
|
||||
internalQueryGeneric,
|
||||
componentsGeneric,
|
||||
} from "convex/server";
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,22 +7,11 @@ import {
|
||||
} from '@convex-dev/auth/server';
|
||||
import { api } from './_generated/api';
|
||||
import { type Id } from './_generated/dataModel';
|
||||
import {
|
||||
action,
|
||||
mutation,
|
||||
query,
|
||||
type MutationCtx,
|
||||
type QueryCtx,
|
||||
} from './_generated/server';
|
||||
import Authentik from '@auth/core/providers/authentik';
|
||||
import { Entra, Password, validatePassword } from './custom/auth';
|
||||
import { action, mutation, query } from './_generated/server';
|
||||
import Password from './CustomPassword';
|
||||
|
||||
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
providers: [
|
||||
Authentik({ allowDangerousEmailAccountLinking: true }),
|
||||
Entra,
|
||||
Password,
|
||||
],
|
||||
providers: [Password],
|
||||
});
|
||||
|
||||
export const PASSWORD_MIN = 8;
|
||||
@@ -30,120 +19,96 @@ export const PASSWORD_MAX = 100;
|
||||
export const PASSWORD_REGEX =
|
||||
/^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u;
|
||||
|
||||
type RWCtx = MutationCtx | QueryCtx;
|
||||
type User = {
|
||||
id: Id<'users'>;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image: Id<'_storage'> | null;
|
||||
lunchTime: string | null;
|
||||
automaticLunch: boolean;
|
||||
provider: string | null;
|
||||
};
|
||||
|
||||
const getUserData = async (ctx: RWCtx, userId: Id<'users'>): Promise<User> => {
|
||||
export const getUser = query(async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return null;
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) throw new ConvexError('User not found.');
|
||||
|
||||
const image: Id<'_storage'> | null =
|
||||
typeof user.image === 'string' && user.image.length > 0
|
||||
? (user.image as Id<'_storage'>)
|
||||
: null;
|
||||
|
||||
const authAccount = await getUserAuthAccountData(ctx, userId);
|
||||
|
||||
return {
|
||||
id: user._id,
|
||||
email: user.email ?? null,
|
||||
name: user.name ?? null,
|
||||
image,
|
||||
lunchTime: user.lunchTime ?? null,
|
||||
automaticLunch: user.automaticLunch ?? false,
|
||||
provider: authAccount?.provider ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const getUserAuthAccountData = async (ctx: RWCtx, userId: Id<'users'>) => {
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) throw new ConvexError('User not found.');
|
||||
const authAccountData = await ctx.db
|
||||
.query('authAccounts')
|
||||
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId))
|
||||
.first();
|
||||
return authAccountData;
|
||||
};
|
||||
|
||||
export const getUser = query({
|
||||
args: { userId: v.optional(v.id('users')) },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = args.userId ?? await getAuthUserId(ctx);
|
||||
if (!userId) return null;
|
||||
return getUserData(ctx, userId);
|
||||
},
|
||||
});
|
||||
|
||||
export const getUserAuthAccount = query(async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return null;
|
||||
return getUserAuthAccountData(ctx, userId);
|
||||
});
|
||||
|
||||
export const getAllUsers = query(async (ctx) => {
|
||||
const users = await ctx.db.query('users').collect();
|
||||
return Promise.all(users.map((u) => getUserData(ctx, u._id)));
|
||||
return users.map((u) => ({
|
||||
id: u._id,
|
||||
email: u.email ?? null,
|
||||
name: u.name ?? null,
|
||||
image: u.image ?? null,
|
||||
}));
|
||||
});
|
||||
|
||||
export const getAllUserIds = query(async (ctx) => {
|
||||
const users = await ctx.db.query('users').collect();
|
||||
return users.map((u) => u._id);
|
||||
const userIds = users.map((u) => u._id);
|
||||
return userIds;
|
||||
});
|
||||
|
||||
export const updateUser = mutation({
|
||||
export const updateUserName = mutation({
|
||||
args: {
|
||||
name: v.optional(v.string()),
|
||||
email: v.optional(v.string()),
|
||||
image: v.optional(v.id('_storage')),
|
||||
lunchTime: v.optional(v.string()),
|
||||
automaticLunch: v.optional(v.boolean()),
|
||||
name: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
handler: async (ctx, { name }) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) throw new ConvexError('User not found.');
|
||||
if (args.lunchTime !== undefined && !args.lunchTime.includes(':')) {
|
||||
throw new ConvexError('Lunch time is invalid.');
|
||||
}
|
||||
const patch: Partial<{
|
||||
name: string;
|
||||
email: string;
|
||||
image: Id<'_storage'>;
|
||||
lunchTime: string;
|
||||
automaticLunch: boolean;
|
||||
}> = {};
|
||||
|
||||
if (args.name !== undefined) patch.name = args.name;
|
||||
if (args.email !== undefined) patch.email = args.email;
|
||||
if (args.lunchTime !== undefined) patch.lunchTime = args.lunchTime;
|
||||
if (args.automaticLunch !== undefined)
|
||||
patch.automaticLunch = args.automaticLunch;
|
||||
|
||||
if (args.image !== undefined) {
|
||||
const oldImage = user.image as Id<'_storage'> | undefined;
|
||||
patch.image = args.image;
|
||||
if (oldImage && oldImage !== args.image) {
|
||||
await ctx.storage.delete(oldImage);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await ctx.db.patch(userId, patch);
|
||||
}
|
||||
|
||||
await ctx.db.patch(userId, { name });
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateUserEmail = mutation({
|
||||
args: {
|
||||
email: v.string(),
|
||||
},
|
||||
handler: async (ctx, { email }) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) throw new ConvexError('User not found.');
|
||||
await ctx.db.patch(userId, { email });
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateUserImage = mutation({
|
||||
args: {
|
||||
storageId: v.id('_storage'),
|
||||
},
|
||||
handler: async (ctx, { storageId }) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) throw new ConvexError('User not found.');
|
||||
const oldImage = user.image as Id<'_storage'> | undefined;
|
||||
await ctx.db.patch(userId, { image: storageId });
|
||||
if (oldImage && oldImage !== storageId) await ctx.storage.delete(oldImage);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const validatePassword = (password: string): boolean => {
|
||||
if (
|
||||
password.length < 8 ||
|
||||
password.length > 100 ||
|
||||
!/\d/.test(password) ||
|
||||
!/[a-z]/.test(password) ||
|
||||
!/[A-Z]/.test(password)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const updateUserPassword = action({
|
||||
args: {
|
||||
currentPassword: v.string(),
|
||||
@@ -152,7 +117,7 @@ export const updateUserPassword = action({
|
||||
handler: async (ctx, { currentPassword, newPassword }) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
const user = await ctx.runQuery(api.auth.getUser, { userId });
|
||||
const user = await ctx.runQuery(api.auth.getUser);
|
||||
if (!user?.email) throw new ConvexError('User not found.');
|
||||
const verified = await retrieveAccount(ctx, {
|
||||
provider: 'password',
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
// convex/crons.ts
|
||||
import { cronJobs } from 'convex/server';
|
||||
import { api } from './_generated/api';
|
||||
|
||||
// Cron order: Minute Hour DayOfMonth Month DayOfWeek
|
||||
const crons = cronJobs();
|
||||
|
||||
// Runs at 5:00 PM America/Chicago, Monday–Friday.
|
||||
// Convex will handle DST if your project version supports `timeZone`.
|
||||
crons.cron(
|
||||
// Run at 7:00 AM CST / 8:00 AM CDT
|
||||
// Only on weekdays
|
||||
'Schedule Automatic Lunches',
|
||||
'0 13 * * 1-5',
|
||||
api.statuses.automaticLunch,
|
||||
);
|
||||
|
||||
crons.cron(
|
||||
// Run at 4:00 PM CST / 5:00 PM CDT
|
||||
// Only on weekdays
|
||||
'End of shift (weekdays 5pm CT)',
|
||||
'0 22 * * 1-5',
|
||||
api.statuses.endOfShiftUpdate,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { Entra } from './providers/entra';
|
||||
export { Password, validatePassword } from './providers/password';
|
||||
export { UseSendOTP, UseSendOTPPasswordReset } from './providers/usesend';
|
||||
@@ -1,31 +0,0 @@
|
||||
import { type AuthProviderMaterializedConfig } from '@convex-dev/auth/server';
|
||||
|
||||
export const Entra: AuthProviderMaterializedConfig = {
|
||||
id: 'microsoft-entra-id',
|
||||
name: 'Microsoft Entra ID',
|
||||
type: 'oauth',
|
||||
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER!,
|
||||
client: {
|
||||
id: process.env.AUTH_MICROSOFT_ENTRA_ID_ID!,
|
||||
secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
|
||||
},
|
||||
authorization: {
|
||||
url: process.env.AUTH_MICROSOFT_ENTRA_ID_AUTH_URL!,
|
||||
params: {
|
||||
scope: 'openid profile email offline_access',
|
||||
response_type: 'code',
|
||||
},
|
||||
},
|
||||
token:
|
||||
'https://login.microsoftonline.com/16200986-86f1-44d2-974c-cfa99352722c/oauth2/v2.0/token',
|
||||
userinfo: 'https://graph.microsoft.com/oidc/userinfo',
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
//image: profile.picture,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
|
||||
import { DataModel } from '../../../_generated/dataModel';
|
||||
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
|
||||
import { ConvexError } from 'convex/values';
|
||||
|
||||
export const Password = DefaultPassword<DataModel>({
|
||||
profile(params, ctx) {
|
||||
return {
|
||||
email: params.email as string,
|
||||
name: params.name as string,
|
||||
};
|
||||
},
|
||||
validatePasswordRequirements: (password: string) => {
|
||||
if (!validatePassword(password)) {
|
||||
throw new ConvexError('Invalid password.');
|
||||
}
|
||||
},
|
||||
reset: UseSendOTPPasswordReset,
|
||||
verify: UseSendOTP,
|
||||
});
|
||||
|
||||
export const validatePassword = (password: string): boolean => {
|
||||
if (
|
||||
password.length < 8 ||
|
||||
password.length > 100 ||
|
||||
!/\d/.test(password) ||
|
||||
!/[a-z]/.test(password) ||
|
||||
!/[A-Z]/.test(password)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { EmailConfig, EmailUserConfig } from '@auth/core/providers/email';
|
||||
import { alphabet } from 'oslo/crypto';
|
||||
import { generateRandomString, RandomReader } from '@oslojs/crypto/random';
|
||||
import { UseSend } from 'usesend-js';
|
||||
|
||||
export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
|
||||
return {
|
||||
id: 'usesend',
|
||||
type: 'email',
|
||||
name: 'UseSend',
|
||||
from: 'TechTracker <admin@techtracker.gbrown.org>',
|
||||
maxAge: 24 * 60 * 60, // 24 hours
|
||||
|
||||
async generateVerificationToken() {
|
||||
const random: RandomReader = {
|
||||
read(bytes) {
|
||||
crypto.getRandomValues(bytes);
|
||||
},
|
||||
};
|
||||
return generateRandomString(random, alphabet('0-9'), 6);
|
||||
},
|
||||
|
||||
async sendVerificationRequest(params) {
|
||||
const { identifier: to, provider, url, theme, token } = params;
|
||||
//const { host } = new URL(url);
|
||||
const host = 'TechTracker';
|
||||
|
||||
const useSend = new UseSend(
|
||||
process.env.AUTH_USESEND_API_KEY!,
|
||||
'https://usesend.gbrown.org',
|
||||
);
|
||||
|
||||
// For password reset, we want to send the code, not the magic link
|
||||
const isPasswordReset =
|
||||
url.includes('reset') || provider.id?.includes('reset');
|
||||
|
||||
const result = await useSend.emails.send({
|
||||
from: provider.from!,
|
||||
to: [to],
|
||||
subject: isPasswordReset
|
||||
? `Reset your password - ${host}`
|
||||
: `Sign in to ${host}`,
|
||||
text: isPasswordReset
|
||||
? `Your password reset code is ${token}`
|
||||
: `Your sign in code is ${token}`,
|
||||
html: isPasswordReset
|
||||
? `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>You requested a password reset. Your reset code is:</p>
|
||||
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
|
||||
${token}
|
||||
</div>
|
||||
<p>This code expires in 1 hour.</p>
|
||||
<p>If you didn't request this, please ignore this email.</p>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2>Your Sign In Code</h2>
|
||||
<p>Your verification code is:</p>
|
||||
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
|
||||
${token}
|
||||
</div>
|
||||
<p>This code expires in 24 hours.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error('UseSend error: ' + JSON.stringify(result.error));
|
||||
}
|
||||
},
|
||||
|
||||
options: config,
|
||||
};
|
||||
}
|
||||
|
||||
// Create specific instances for password reset and email verification
|
||||
export const UseSendOTPPasswordReset = UseSendProvider({
|
||||
id: 'usesend-otp-password-reset',
|
||||
apiKey: process.env.AUTH_USESEND_API_KEY,
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
});
|
||||
|
||||
export const UseSendOTP = UseSendProvider({
|
||||
id: 'usesend-otp',
|
||||
apiKey: process.env.AUTH_USESEND_API_KEY,
|
||||
maxAge: 60 * 20, // 20 minutes
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineSchema, defineTable } from 'convex/server';
|
||||
import { v, VId } from 'convex/values';
|
||||
import { v } from 'convex/values';
|
||||
import { authTables } from '@convex-dev/auth/server';
|
||||
|
||||
// The schema is normally optional, but Convex Auth
|
||||
@@ -12,8 +12,6 @@ export default defineSchema({
|
||||
image: v.optional(v.string()),
|
||||
email: v.optional(v.string()),
|
||||
currentStatusId: v.optional(v.id('statuses')),
|
||||
lunchTime: v.optional(v.string()),
|
||||
automaticLunch: v.optional(v.boolean()),
|
||||
emailVerificationTime: v.optional(v.number()),
|
||||
phone: v.optional(v.string()),
|
||||
phoneVerificationTime: v.optional(v.number()),
|
||||
@@ -26,7 +24,6 @@ export default defineSchema({
|
||||
message: v.string(),
|
||||
updatedAt: v.number(),
|
||||
updatedBy: v.optional(v.id('users')),
|
||||
persistentStatus: v.optional(v.boolean()),
|
||||
})
|
||||
.index('by_user', ['userId'])
|
||||
.index('by_user_updatedAt', ['userId', 'updatedAt']),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type MutationCtx,
|
||||
type QueryCtx,
|
||||
action,
|
||||
internalMutation,
|
||||
mutation,
|
||||
query,
|
||||
} from './_generated/server';
|
||||
@@ -12,6 +13,7 @@ import type { Doc, Id } from './_generated/dataModel';
|
||||
import { paginationOptsValidator } from 'convex/server';
|
||||
|
||||
type RWCtx = MutationCtx | QueryCtx;
|
||||
|
||||
type StatusRow = {
|
||||
user: {
|
||||
id: Id<'users'>;
|
||||
@@ -24,7 +26,6 @@ type StatusRow = {
|
||||
message: string;
|
||||
updatedAt: number;
|
||||
updatedBy: StatusRow['user'] | null;
|
||||
persistentStatus: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -34,73 +35,72 @@ type Paginated<T> = {
|
||||
continueCursor: string | null;
|
||||
};
|
||||
|
||||
// CHANGED: typed helpers
|
||||
const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => {
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) throw new ConvexError('User not found.');
|
||||
return user;
|
||||
};
|
||||
|
||||
const getName = (u: Doc<'users'>): string | null =>
|
||||
'name' in u && typeof u.name === 'string' ? u.name : null;
|
||||
|
||||
const getEmail = (u: Doc<'users'>): string | null =>
|
||||
'email' in u && typeof u.email === 'string' ? u.email : null;
|
||||
|
||||
const getImageId = (u: Doc<'users'>): Id<'_storage'> | null => {
|
||||
if (!('image' in u)) return null;
|
||||
const img = (u as { image?: unknown }).image as string | undefined;
|
||||
return img && img.length > 0 ? (img as Id<'_storage'>) : null;
|
||||
const latestStatusForOwner = async (ctx: RWCtx, ownerId: Id<'users'>) => {
|
||||
const [latest] = await ctx.db
|
||||
.query('statuses')
|
||||
.withIndex('by_user_updatedAt', (q) => q.eq('userId', ownerId))
|
||||
.order('desc')
|
||||
.take(1);
|
||||
return latest as Doc<'statuses'> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new status for a single user.
|
||||
* - Defaults userId to the caller.
|
||||
* - updatedBy defaults to the caller.
|
||||
* - Updates the user's currentStatusId pointer.
|
||||
*/
|
||||
export const create = mutation({
|
||||
args: {
|
||||
message: v.string(),
|
||||
userId: v.optional(v.id('users')),
|
||||
updatedBy: v.optional(v.id('users')),
|
||||
persistentStatus: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const authUserId: Id<'users'> | null = await getAuthUserId(ctx);
|
||||
if (!args.userId && !authUserId) {
|
||||
throw new ConvexError('Not authenticated.');
|
||||
}
|
||||
const authUserId = await getAuthUserId(ctx);
|
||||
if (!authUserId) throw new ConvexError('Not authenticated.');
|
||||
|
||||
const userId = args.userId ?? authUserId;
|
||||
await ensureUser(ctx, userId);
|
||||
|
||||
const updatedBy = args.updatedBy ?? authUserId;
|
||||
await ensureUser(ctx, updatedBy);
|
||||
|
||||
const message = args.message.trim();
|
||||
if (message.length === 0) {
|
||||
throw new ConvexError('Message cannot be empty.');
|
||||
}
|
||||
const userId = args.userId ?? authUserId!;
|
||||
const updatedBy = args.updatedBy ?? authUserId;
|
||||
const persistentStatus = args.persistentStatus ?? false;
|
||||
await ensureUser(ctx, userId);
|
||||
let statusId: Id<'statuses'>;
|
||||
if (updatedBy) {
|
||||
ensureUser(ctx, updatedBy);
|
||||
statusId = await ctx.db.insert('statuses', {
|
||||
message,
|
||||
userId,
|
||||
updatedBy,
|
||||
updatedAt: Date.now(),
|
||||
persistentStatus,
|
||||
});
|
||||
} else {
|
||||
statusId = await ctx.db.insert('statuses', {
|
||||
message,
|
||||
userId,
|
||||
updatedAt: Date.now(),
|
||||
persistentStatus,
|
||||
});
|
||||
}
|
||||
|
||||
const statusId = await ctx.db.insert('statuses', {
|
||||
message,
|
||||
userId,
|
||||
updatedBy,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
await ctx.db.patch(userId, { currentStatusId: statusId });
|
||||
|
||||
return { statusId };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Bulk create the same status for many users.
|
||||
* - updatedBy defaults to the caller.
|
||||
* - Updates each user's currentStatusId pointer.
|
||||
*/
|
||||
export const bulkCreate = mutation({
|
||||
args: {
|
||||
message: v.string(),
|
||||
userIds: v.array(v.id('users')),
|
||||
updatedBy: v.optional(v.id('users')),
|
||||
persistentStatus: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const authUserId = await getAuthUserId(ctx);
|
||||
@@ -109,7 +109,6 @@ export const bulkCreate = mutation({
|
||||
if (args.userIds.length === 0) return { statusIds: [] };
|
||||
|
||||
const updatedBy = args.updatedBy ?? authUserId;
|
||||
const persistentStatus = args.persistentStatus ?? false;
|
||||
await ensureUser(ctx, updatedBy);
|
||||
|
||||
const message = args.message.trim();
|
||||
@@ -120,130 +119,91 @@ export const bulkCreate = mutation({
|
||||
const statusIds: Id<'statuses'>[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// Sequential to keep load predictable; switch to Promise.all
|
||||
// if your ownerIds lists are small and bounded.
|
||||
for (const userId of args.userIds) {
|
||||
await ensureUser(ctx, userId);
|
||||
|
||||
const statusId = await ctx.db.insert('statuses', {
|
||||
message,
|
||||
userId,
|
||||
updatedBy,
|
||||
updatedAt: now,
|
||||
persistentStatus,
|
||||
});
|
||||
|
||||
await ctx.db.patch(userId, { currentStatusId: statusId });
|
||||
statusIds.push(statusId);
|
||||
}
|
||||
return { statusIds };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Update all statuses for all users.
|
||||
*/
|
||||
export const updateAllStatuses = mutation({
|
||||
args: { message: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userIds = await ctx.runQuery(api.auth.getAllUserIds);
|
||||
const updatedAt = Date.now();
|
||||
const statusIds: Id<'statuses'>[] = [];
|
||||
for (const userId of userIds) {
|
||||
await ensureUser(ctx, userId);
|
||||
const statusId = await ctx.db.insert('statuses', {
|
||||
message: args.message,
|
||||
userId,
|
||||
updatedAt,
|
||||
});
|
||||
await ctx.db.patch(userId, { currentStatusId: statusId });
|
||||
statusIds.push(statusId);
|
||||
}
|
||||
|
||||
return { statusIds };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateAllStatuses = mutation({
|
||||
args: {
|
||||
message: v.string(),
|
||||
persistentStatus: v.optional(v.boolean())
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const users = await ctx.db.query('users').collect();
|
||||
const message = args.message.trim();
|
||||
const persistentStatus = args.persistentStatus ?? false;
|
||||
if (message.length === 0) {
|
||||
throw new ConvexError('Message cannot be empty.');
|
||||
}
|
||||
|
||||
const statusIds: Id<'statuses'>[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const user of users) {
|
||||
const curStatus = await ctx.runQuery(api.statuses.getCurrentForUser, {
|
||||
userId: user._id,
|
||||
});
|
||||
if (!curStatus?.persistentStatus) {
|
||||
const statusId = await ctx.db.insert('statuses', {
|
||||
message,
|
||||
userId: user._id,
|
||||
updatedAt: now,
|
||||
persistentStatus,
|
||||
});
|
||||
await ctx.db.patch(user._id, { currentStatusId: statusId });
|
||||
statusIds.push(statusId);
|
||||
}
|
||||
}
|
||||
return { statusIds };
|
||||
},
|
||||
});
|
||||
|
||||
export const createLunchStatus = mutation({
|
||||
args: { userId: v.optional(v.id('users'))},
|
||||
handler: async (ctx, args) => {
|
||||
const authUserId = await getAuthUserId(ctx);
|
||||
const lunchUserId = args.userId ?? authUserId
|
||||
if (!lunchUserId) throw new ConvexError('Not authenticated.');
|
||||
const curStatus = await ctx.runQuery(api.statuses.getCurrentForUser, {
|
||||
userId: lunchUserId,
|
||||
});
|
||||
if (curStatus?.persistentStatus) {
|
||||
return { success: false, error: 'Current status is persistent.'};
|
||||
}
|
||||
await ctx.runMutation(api.statuses.create, {
|
||||
message: 'At lunch',
|
||||
userId: lunchUserId,
|
||||
});
|
||||
const oneHour = 60 * 60 * 1000;
|
||||
await ctx.scheduler.runAfter(oneHour, api.statuses.backFromLunchStatus, {
|
||||
userId: lunchUserId,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const backFromLunchStatus = mutation({
|
||||
args: { userId: v.optional(v.id('users')) },
|
||||
handler: async (ctx, args) => {
|
||||
const authUserId = await getAuthUserId(ctx);
|
||||
const lunchUserId = args.userId ?? authUserId
|
||||
if (!lunchUserId) throw new ConvexError('Not authenticated.');
|
||||
const curStatus = await ctx.runQuery(api.statuses.getCurrentForUser, {
|
||||
userId: lunchUserId,
|
||||
});
|
||||
if (curStatus?.persistentStatus) {
|
||||
return { success: false, error: 'Current status is persistent.'};
|
||||
}
|
||||
const user = await ensureUser(ctx, lunchUserId);
|
||||
if (!user.currentStatusId) throw new ConvexError('User has no current status.');
|
||||
const currentStatus = await ctx.db.get(user.currentStatusId);
|
||||
if (currentStatus?.message === 'At lunch') {
|
||||
await ctx.runMutation(api.statuses.create, {
|
||||
message: 'At desk',
|
||||
userId: lunchUserId,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Current status for a specific user.
|
||||
* - Uses users.currentStatusId if present,
|
||||
* otherwise falls back to latest by index.
|
||||
*/
|
||||
export const getCurrentForUser = query({
|
||||
args: { userId: v.id('users') },
|
||||
handler: async (ctx, { userId }) => {
|
||||
const user = await ensureUser(ctx, userId);
|
||||
|
||||
if (user.currentStatusId) {
|
||||
const status = await ctx.db.get(user.currentStatusId);
|
||||
if (status) return status;
|
||||
}
|
||||
const [latest] = await ctx.db
|
||||
.query('statuses')
|
||||
.withIndex('by_user_updatedAt', (q) => q.eq('userId', userId))
|
||||
.order('desc')
|
||||
.take(1);
|
||||
|
||||
return latest ?? null;
|
||||
return await latestStatusForOwner(ctx, userId);
|
||||
},
|
||||
});
|
||||
|
||||
const getName = (u: Doc<'users'>): string | null =>
|
||||
'name' in u && typeof u.name === 'string' ? u.name : null;
|
||||
const getEmail = (u: Doc<'users'>): string | null =>
|
||||
'email' in u && typeof u.email === 'string' ? u.email : null;
|
||||
|
||||
const getImageId = (u: Doc<'users'>): Id<'_storage'> | null => {
|
||||
if (!('image' in u)) return null;
|
||||
const img = (u as { image?: unknown }).image as string | undefined;
|
||||
return img && img.length > 0 ? (img as Id<'_storage'>) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Current statuses for all users.
|
||||
* - Reads each user's currentStatusId pointer.
|
||||
* - Falls back to latest-by-index if pointer is missing.
|
||||
*/
|
||||
export const getCurrentForAll = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<StatusRow[]> => {
|
||||
const users = await ctx.db.query('users').collect();
|
||||
|
||||
return await Promise.all(
|
||||
users.map(async (u) => {
|
||||
// Resolve user's current or latest status
|
||||
let curStatus: Doc<'statuses'> | null = null;
|
||||
if ('currentStatusId' in u && u.currentStatusId) {
|
||||
curStatus = await ctx.db.get(u.currentStatusId);
|
||||
@@ -256,35 +216,39 @@ export const getCurrentForAll = query({
|
||||
.take(1);
|
||||
curStatus = latest ?? null;
|
||||
}
|
||||
|
||||
// User display + URL
|
||||
const userImageId = getImageId(u);
|
||||
const userImageUrl = userImageId
|
||||
? await ctx.storage.getUrl(userImageId)
|
||||
: null;
|
||||
|
||||
// Updated by (if different) + URL
|
||||
let updatedByUser: StatusRow['user'] | null = null;
|
||||
if (curStatus && curStatus.updatedBy && curStatus.updatedBy !== u._id) {
|
||||
const updater = await ctx.db.get(curStatus.updatedBy);
|
||||
if (updater) {
|
||||
const updaterImageId = getImageId(updater);
|
||||
const updaterImageUrl = updaterImageId
|
||||
? await ctx.storage.getUrl(updaterImageId)
|
||||
: null;
|
||||
updatedByUser = {
|
||||
id: updater._id,
|
||||
email: getEmail(updater),
|
||||
name: getName(updater),
|
||||
imageUrl: updaterImageUrl,
|
||||
};
|
||||
}
|
||||
if (!updater) throw new ConvexError('Updater not found.');
|
||||
const updaterImageId = getImageId(updater);
|
||||
const updaterImageUrl = updaterImageId
|
||||
? await ctx.storage.getUrl(updaterImageId)
|
||||
: null;
|
||||
updatedByUser = {
|
||||
id: updater._id,
|
||||
email: getEmail(updater),
|
||||
name: getName(updater),
|
||||
imageUrl: updaterImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const status: StatusRow['status'] = curStatus
|
||||
? {
|
||||
id: curStatus._id,
|
||||
message: curStatus.message,
|
||||
updatedAt: curStatus.updatedAt,
|
||||
updatedBy: updatedByUser,
|
||||
persistentStatus: curStatus.persistentStatus ?? false,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: u._id,
|
||||
@@ -299,7 +263,9 @@ export const getCurrentForAll = query({
|
||||
},
|
||||
});
|
||||
|
||||
// Paginated history
|
||||
/**
|
||||
* Paginated history for all users or for a specific user.
|
||||
*/
|
||||
export const listHistory = query({
|
||||
args: {
|
||||
userId: v.optional(v.id('users')),
|
||||
@@ -309,6 +275,7 @@ export const listHistory = query({
|
||||
ctx,
|
||||
{ userId, paginationOpts },
|
||||
): Promise<Paginated<StatusRow>> => {
|
||||
// Query statuses newest-first, optionally filtered by user
|
||||
const result = userId
|
||||
? await ctx.db
|
||||
.query('statuses')
|
||||
@@ -316,15 +283,21 @@ export const listHistory = query({
|
||||
.order('desc')
|
||||
.paginate(paginationOpts)
|
||||
: await ctx.db.query('statuses').order('desc').paginate(paginationOpts);
|
||||
|
||||
// Cache user display objects to avoid refetching repeatedly
|
||||
const displayCache = new Map<string, StatusRow['user']>();
|
||||
|
||||
const getDisplay = async (uid: Id<'users'>): Promise<StatusRow['user']> => {
|
||||
const key = uid as unknown as string;
|
||||
const cached = displayCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const user = await ctx.db.get(uid);
|
||||
if (!user) throw new ConvexError('User not found.');
|
||||
|
||||
const imgId = getImageId(user);
|
||||
const imgUrl = imgId ? await ctx.storage.getUrl(imgId) : null;
|
||||
|
||||
const display: StatusRow['user'] = {
|
||||
id: user._id,
|
||||
email: getEmail(user),
|
||||
@@ -334,6 +307,7 @@ export const listHistory = query({
|
||||
displayCache.set(key, display);
|
||||
return display;
|
||||
};
|
||||
|
||||
const statuses: StatusRow[] = [];
|
||||
for (const s of result.page) {
|
||||
const owner = await getDisplay(s.userId);
|
||||
@@ -341,6 +315,7 @@ export const listHistory = query({
|
||||
s.updatedBy && s.updatedBy !== s.userId
|
||||
? await getDisplay(s.updatedBy)
|
||||
: null;
|
||||
|
||||
statuses.push({
|
||||
user: owner,
|
||||
status: {
|
||||
@@ -348,12 +323,15 @@ export const listHistory = query({
|
||||
message: s.message,
|
||||
updatedAt: s.updatedAt,
|
||||
updatedBy,
|
||||
persistentStatus: s.persistentStatus ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
const page = statuses.sort(
|
||||
(a, b) => (b.status?.updatedAt ?? 0) - (a.status?.updatedAt ?? 0),
|
||||
);
|
||||
|
||||
return {
|
||||
page: statuses,
|
||||
page,
|
||||
isDone: result.isDone,
|
||||
continueCursor: result.continueCursor,
|
||||
};
|
||||
@@ -367,49 +345,17 @@ export const endOfShiftUpdate = action({
|
||||
timeZone: 'America/Chicago',
|
||||
}),
|
||||
);
|
||||
const day = now.getDay(),
|
||||
hour = now.getHours(),
|
||||
minute = now.getMinutes();
|
||||
const day = now.getDay();
|
||||
const hour = now.getHours();
|
||||
const minute = now.getMinutes();
|
||||
if (day == 0 || day === 6) return;
|
||||
const message = day === 5 ? 'Enjoying the weekend' : 'End of shift';
|
||||
if (hour === 17) {
|
||||
await ctx.runMutation(api.statuses.updateAllStatuses, { message });
|
||||
} else if (hour === 16) {
|
||||
if (hour === 5) {
|
||||
await ctx.runMutation(api.statuses.updateAllStatuses, {
|
||||
message: 'End of shift',
|
||||
});
|
||||
} else if (hour === 4) {
|
||||
const ms = ((60 - minute) % 60) * 60 * 1000;
|
||||
await ctx.scheduler.runAfter(ms, api.statuses.endOfShiftUpdate);
|
||||
} else return;
|
||||
},
|
||||
});
|
||||
|
||||
export const automaticLunch = action({
|
||||
handler: async (ctx) => {
|
||||
const now = new Date(
|
||||
new Date().toLocaleString('en-US', {
|
||||
timeZone: 'America/Chicago',
|
||||
}),
|
||||
);
|
||||
const users = await ctx.runQuery(api.auth.getAllUsers);
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
if (user.automaticLunch && user.lunchTime) {
|
||||
const [hours, minutes] = user.lunchTime.split(':').map(Number);
|
||||
const userLunchTime = new Date(now);
|
||||
userLunchTime.setHours(hours, minutes, 0, 0);
|
||||
const diffInMs = userLunchTime.getTime() - now.getTime();
|
||||
// Only schedule if lunch is in the future today
|
||||
if (diffInMs > 0) {
|
||||
await ctx.scheduler.runAfter(
|
||||
diffInMs,
|
||||
api.statuses.createLunchStatus,
|
||||
{ userId: user.id },
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`Skipped ${user.name} - lunch time ${user.lunchTime} already passed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Convex
|
||||
CONVEX_SELF_HOSTED_URL=
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=
|
||||
SETUP_SCRIPT_RAN=
|
||||
CONVEX_URL=
|
||||
SITE_URL=
|
||||
AUTH_URL=
|
||||
CONVEX_SITE_URL=
|
||||
# Authentik Environment Variables
|
||||
AUTH_AUTHENTIK_ID=
|
||||
AUTH_AUTHENTIK_SECRET=
|
||||
AUTH_AUTHENTIK_ISSUER=
|
||||
# Microsoft Entra ID
|
||||
AUTH_MICROSOFT_ENTRA_ID_ID=
|
||||
AUTH_MICROSOFT_ENTRA_ID_SECRET=
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER=
|
||||
AUTH_MICROSOFT_ENTRA_ID_AUTH_URL=
|
||||
# UseSend
|
||||
AUTH_USESEND_API_KEY=
|
||||
@@ -4,23 +4,15 @@
|
||||
"description": "Convex Backend for Tech Tracker",
|
||||
"scripts": {
|
||||
"dev": "convex dev",
|
||||
"dev:tunnel": "convex dev",
|
||||
"predev": "convex dev --until-success && convex dev --once --run-sh \"node setup.mjs --once\" && convex dashboard",
|
||||
"setup": "convex dev --until-success"
|
||||
},
|
||||
"author": "Gib",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@react-email/components": "0.5.4",
|
||||
"@react-email/render": "^1.4.0",
|
||||
"convex": "^1.28.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"usesend-js": "^1.5.6"
|
||||
"convex": "^1.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-email": "4.2.11",
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,6 @@
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"dev:tunnel": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"lint": {},
|
||||
"clean": {
|
||||
"cache": false
|
||||
|
||||
Reference in New Issue
Block a user