diff --git a/.gitignore b/.gitignore index f3e878f..6ad00c6 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,7 @@ frontend/android/ # Expo frontend/expo-env.d.ts + + + +CLAUDE.md diff --git a/README.md b/README.md index bd204b5..633ad9a 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,6 @@ This project is currently under semi-active development as a learning and experi ## Notes This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful + + +need to made .creds .env and init directory with subjects.txt diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java b/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java index 76520ce..3524874 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java @@ -3,6 +3,7 @@ package dev.ksan.etfoglasiserver.config; import dev.ksan.etfoglasiserver.service.JWTService; import dev.ksan.etfoglasiserver.service.MyUserDetailsService; import dev.ksan.etfoglasiserver.service.UserService; +import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -36,7 +37,12 @@ public class JwtFilter extends OncePerRequestFilter { if (authHeader != null && authHeader.startsWith("Bearer ")) { token = authHeader.substring(7); - username = jwtService.extractEmail(token); + try { + username = jwtService.extractEmail(token); + } catch (JwtException e) { + // expired, malformed, or otherwise invalid token - treat as unauthenticated + username = null; + } } if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java index 34a6898..744b59a 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java @@ -1,17 +1,13 @@ package dev.ksan.etfoglasiserver.service; -import dev.ksan.etfoglasiserver.model.User; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; -import java.security.NoSuchAlgorithmException; import java.util.*; import java.util.function.Function; -import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; @@ -29,7 +25,7 @@ public class JWTService { return Jwts.builder().claims().add(claims).subject(email) - .issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+60*60*300)) + .issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+1000L*60*60*24*60)) .and().signWith(getKey()).compact(); } diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index 665b4f8..7abd037 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -7,11 +7,20 @@ import { onMessage, onNotificationOpenedApp, getInitialNotification, + setBackgroundMessageHandler, } from '@react-native-firebase/messaging'; -import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications'; +import { registerDeviceToken, listenForTokenRefresh, displayLocalNotification } from '@/services/notifications'; import { AuthProvider, useAuth } from "@/context/AuthContext"; import Toast from 'react-native-toast-message'; +// Registered at module scope so it's installed as soon as this entry file +// loads, which is required for it to fire while the app is backgrounded/killed. +setBackgroundMessageHandler(getMessaging(), async (remoteMessage) => { + const title = remoteMessage.data?.title as string | undefined; + const body = remoteMessage.data?.body as string | undefined; + await displayLocalNotification(title, body); +}); + function NotificationSetup() { const { user, loading } = useAuth(); @@ -27,7 +36,8 @@ function NotificationSetup() { console.log('Foreground notification:', msg); Toast.show({ type: 'info', - text1: msg.notification?.title ?? 'New Notification', + text1: (msg.data?.title as string | undefined) ?? 'New Notification', + text2: msg.data?.body as string | undefined, position: 'top', visibilityTime: 4000, }); diff --git a/frontend/context/AuthContext.tsx b/frontend/context/AuthContext.tsx index 7b40d73..15db829 100644 --- a/frontend/context/AuthContext.tsx +++ b/frontend/context/AuthContext.tsx @@ -1,9 +1,10 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import Toast from "react-native-toast-message"; import { authApi, - LoginPayload, RegisterPayload, subscriptionsApi, userApi, UserUpdateDTO, + LoginPayload, RegisterPayload, setUnauthorizedHandler, subscriptionsApi, userApi, UserUpdateDTO, } from "@/services/api"; type User = { @@ -45,6 +46,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(u); }; + useEffect(() => { + setUnauthorizedHandler(() => { + persist(null); + Toast.show({ + type: 'info', + text1: 'Session expired', + text2: 'Please sign in again.', + position: 'top', + visibilityTime: 4000, + }); + }); + return () => setUnauthorizedHandler(null); + }, []); + const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => { if (!user) return; await userApi.updateNotificationType(type, user.token); diff --git a/frontend/services/api.tsx b/frontend/services/api.tsx index ad13fef..63d3d2f 100644 --- a/frontend/services/api.tsx +++ b/frontend/services/api.tsx @@ -6,6 +6,14 @@ function authHeader(token?: string) { return token ? { Authorization: `Bearer ${token}` } : {}; } +let onUnauthorized: (() => void) | null = null; + +// Called once from AuthProvider so api.tsx can trigger a logout/relogin +// when the backend rejects a request due to a missing/expired/invalid token. +export function setUnauthorizedHandler(handler: (() => void) | null) { + onUnauthorized = handler; +} + async function request( method: string, path: string, @@ -30,6 +38,7 @@ async function request( console.log('Failed URL:', res.url); console.log('Status:', res.status); console.log('Response:', errorText); + if (res.status === 401 && token) onUnauthorized?.(); throw new Error(errorText); } return res.json(); diff --git a/frontend/services/notifications.tsx b/frontend/services/notifications.tsx index b20bdba..ef4b6bf 100644 --- a/frontend/services/notifications.tsx +++ b/frontend/services/notifications.tsx @@ -5,6 +5,7 @@ import { onTokenRefresh, AuthorizationStatus, } from '@react-native-firebase/messaging'; +import notifee, { AndroidImportance } from '@notifee/react-native'; import { post } from '@/services/api'; export async function registerDeviceToken(authToken: string): Promise { @@ -32,4 +33,23 @@ export function listenForTokenRefresh(authToken: string): () => void { return onTokenRefresh(getMessaging(), async (newToken) => { await post('/device-tokens/register', { token: newToken }, authToken); }); +} + +export async function displayLocalNotification(title?: string, body?: string) { + if (!title && !body) return; + + const channelId = await notifee.createChannel({ + id: 'default', + name: 'General', + importance: AndroidImportance.HIGH, + }); + + await notifee.displayNotification({ + title, + body, + android: { + channelId, + pressAction: { id: 'default' }, + }, + }); } \ No newline at end of file