use user from session

This commit is contained in:
john 2025-05-20 10:06:18 +02:00
parent 5f47162a50
commit 700eaf3eb2
16 changed files with 148 additions and 107 deletions

View file

@ -10,39 +10,17 @@ import LogoutPage from './app/auth/pages/LogoutPage.tsx'
import UnauthorizedHandler from './app/auth/components/UnauthorizedHandler.tsx'
import AdminPage from './app/admin/pages/AdminPage.tsx'
import SignupCodesManagementPage from './app/admin/pages/subpages/SignupCodesManagementPage.tsx'
import { useUser } from './app/user/userStore.ts'
import { useEffect, useMemo } from 'react'
import { initUser } from './app/user/user.ts'
import { useRefreshSessionLoop } from './useRefreshSessionLoop.ts'
const postService = new PostsService()
const mediaService = new MediaService()
const authService = new AuthService()
initUser()
export default function App() {
const postService = new PostsService()
const mediaService = new MediaService()
const authService = useMemo(() => new AuthService(), [])
const { user, setUser } = useUser()
const userId = user?.userId ?? null
useEffect(() => {
if (userId == null) {
return
}
const timeouts: number[] = []
timeouts.push(
setTimeout(async function refreshUser() {
const userInfo = await authService.refreshUser(userId)
setUser(userInfo)
timeouts.push(setTimeout(refreshUser, 60_000))
}),
)
return () => {
timeouts.forEach(clearTimeout)
}
}, [authService, setUser, userId])
useRefreshSessionLoop(authService)
return (
<BrowserRouter>

View file

@ -2,7 +2,7 @@ import { dispatchMessage } from '../messageBus/messageBus.ts'
import client from '../api/client.ts'
import { ProblemDetails } from '../../types'
import { SignupCode } from './signupCode.ts'
import { User } from '../user/userStore.ts'
import { getCookie } from './cookies.ts'
export class AuthService {
constructor() {}
@ -17,7 +17,7 @@ export class AuthService {
throw new Error('invalid credentials')
}
dispatchMessage('auth:logged-in', { ...res.data })
dispatchMessage('auth:logged-in', null)
}
async signup(username: string, password: string, signupCode: string) {
@ -31,7 +31,7 @@ export class AuthService {
throw new Error((res.error as ProblemDetails)?.detail ?? 'invalid credentials')
}
dispatchMessage('auth:registered', { ...res.data })
dispatchMessage('auth:registered', null)
}
async logout() {
@ -65,30 +65,18 @@ export class AuthService {
return res.data.signupCodes.map(SignupCode.fromDto)
}
async refreshUser(userId: string): Promise<User | null> {
if (this.getCookie('hasSession') !== 'true') {
async refreshUser(userId: string) {
if (getCookie('hasSession') !== 'true') {
return null
}
const res = await client.GET(`/auth/user/{userId}`, {
await client.GET(`/auth/user/{userId}`, {
params: {
path: { userId },
},
credentials: 'include',
})
return res.data ?? null
}
private getCookie(cookieName: string): string | undefined {
const cookie = document.cookie
.split('; ')
.map((c) => {
const [name, value] = c.split('=')
return { name, value }
})
.find((c) => c.name === cookieName)
return cookie?.value
dispatchMessage('auth:refreshed', null)
}
}

View file

@ -1,9 +1,9 @@
import { useUser } from '../../user/userStore.ts'
import { useUser } from '../../user/user.ts'
import NavButton from '../../../components/buttons/NavButton.tsx'
import { useLocation } from 'react-router-dom'
export default function AuthNavButtons() {
const { user } = useUser()
const user = useUser()
const { pathname } = useLocation()

View file

@ -1,9 +1,9 @@
import { useUser } from '../../user/userStore.ts'
import { useUser } from '../../user/user.ts'
import { useNavigate, Outlet } from 'react-router-dom'
import { useEffect } from 'react'
export default function Protected() {
const { user } = useUser()
const user = useUser()
const navigate = useNavigate()

View file

@ -1,6 +1,6 @@
import { PropsWithChildren, useEffect, useRef } from 'react'
import { AuthService } from '../authService.ts'
import { useUser } from '../../user/userStore.ts'
import { useUser } from '../../user/user.ts'
interface RefreshUserProps {
authService: AuthService
@ -10,7 +10,7 @@ export default function RefreshUser({
authService,
children,
}: PropsWithChildren<RefreshUserProps>) {
const { user } = useUser()
const user = useUser()
const didRefresh = useRef(false)
useEffect(() => {

16
src/app/auth/cookies.ts Normal file
View file

@ -0,0 +1,16 @@
export function getCookie(cookieName: string): string | undefined {
return getCookies().get(cookieName)
}
export function getCookies(): Map<string, string> {
return document.cookie
.split('; ')
.map((c) => {
const [name, value] = c.split('=') as [string, string]
return { name, value }
})
.reduce((acc, c) => {
acc.set(c.name, c.value)
return acc
}, new Map<string, string>())
}

View file

@ -4,7 +4,7 @@ import TextInput from '../../../components/inputs/TextInput.tsx'
import Button from '../../../components/buttons/Button.tsx'
import { AuthService } from '../authService.ts'
import { useNavigate } from 'react-router-dom'
import { useUser } from '../../user/userStore.ts'
import { useUser } from '../../user/user.ts'
import NavBar from '../../../components/NavBar.tsx'
import NavButton from '../../../components/buttons/NavButton.tsx'
import LinkButton from '../../../components/buttons/LinkButton.tsx'
@ -22,7 +22,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
const passwordInputRef = useRef<HTMLInputElement | null>(null)
const navigate = useNavigate()
const { user } = useUser()
const user = useUser()
useEffect(() => {
if (user) {

View file

@ -1,7 +1,7 @@
import { useNavigate } from 'react-router-dom'
import { AuthService } from '../authService.ts'
import { useEffect } from 'react'
import { useUser } from '../../user/userStore.ts'
import { useUser } from '../../user/user.ts'
interface LogoutPageProps {
authService: AuthService
@ -9,9 +9,10 @@ interface LogoutPageProps {
export default function LogoutPage({ authService }: LogoutPageProps) {
const navigate = useNavigate()
const { user } = useUser()
const user = useUser()
useEffect(() => {
console.debug(user)
if (!user) {
navigate('/login')
}

View file

@ -1,7 +1,7 @@
import { useCallback, useState } from 'react'
import FeedView from '../components/FeedView.tsx'
import { PostsService } from '../posts/postsService.ts'
import { useUser } from '../../user/userStore.ts'
import { useUser } from '../../user/user.ts'
import { MediaService } from '../../media/mediaService.ts'
import NewPostWidget from '../../../components/NewPostWidget.tsx'
import { useFeedViewModel } from '../components/FeedView.ts'
@ -18,7 +18,7 @@ interface HomePageProps {
}
export default function HomePage({ postsService, mediaService }: HomePageProps) {
const { user } = useUser()
const user = useUser()
useSaveSignupCodeToLocalStorage()
const [isSubmitting, setIsSubmitting] = useState(false)

8
src/app/femtoApp.ts Normal file
View file

@ -0,0 +1,8 @@
import { FemtoApp } from '../types'
import { produce } from 'immer'
export function setGlobal<K extends keyof FemtoApp>(k: K, v: FemtoApp[K]) {
window.$femto = produce(window.$femto ?? {}, (draft) => {
draft[k] = v
})
}

View file

@ -1,10 +1,10 @@
import { User } from '../user/userStore.ts'
import { User } from '../user/user.ts'
export interface MessageTypes {
'auth:logged-in': User
'auth:registered': User
'auth:logged-in': null
'auth:registered': null
'auth:logged-out': null
'auth:unauthorized': null
'auth:user-refreshed': User
'auth:user-refresh-failed': null
'auth:refreshed': null
'user:updated': User | null
}

48
src/app/user/user.ts Normal file
View file

@ -0,0 +1,48 @@
import { addMessageListener, dispatchMessage } from '../messageBus/messageBus.ts'
import { getCookie } from '../auth/cookies.ts'
import { useMessageListener } from '../../hooks/useMessageListener.ts'
import { useState } from 'react'
import { setGlobal } from '../femtoApp.ts'
export interface User {
userId: string
username: string
isSuperUser: boolean
}
let globalUser: User | null
export function initUser() {
updateUser()
}
function updateUser() {
globalUser = getUserFromCookie()
console.debug(globalUser)
setGlobal('user', globalUser)
dispatchMessage('user:updated', globalUser)
}
addMessageListener('auth:logged-in', updateUser)
addMessageListener('auth:registered', updateUser)
addMessageListener('auth:logged-out', updateUser)
addMessageListener('auth:refreshed', updateUser)
export function useUser(): User | null {
const [user, setUser] = useState(globalUser)
useMessageListener('user:updated', (u) => {
setUser(u)
})
return user
}
function getUserFromCookie(): User | null {
const userCookie = getCookie('user')
if (!userCookie) return null
// TODO validate but it should be fine
return JSON.parse(decodeURIComponent(userCookie)) as User
}

View file

@ -1,38 +0,0 @@
import { createStore, Store, useStore } from '../../utils/store.ts'
import { addMessageListener } from '../messageBus/messageBus.ts'
export interface User {
userId: string
username: string
isSuperUser: boolean
}
export type UserStore = Store<User | null>
const UserKey = 'user'
export const userStore = createStore<User | null>(loadStoredUser())
userStore.subscribe((user) => {
localStorage.setItem(UserKey, JSON.stringify(user))
})
const setUser = (u: User | null) => userStore.setState(u)
addMessageListener('auth:logged-in', setUser)
addMessageListener('auth:registered', setUser)
addMessageListener('auth:logged-out', setUser)
export const useUser = () => {
const [user, setUser] = useStore(userStore)
return { user, setUser }
}
function loadStoredUser(): User | null {
const json = localStorage.getItem(UserKey)
if (json) {
return JSON.parse(json) as User
} else {
return null
}
}

View file

@ -1,11 +1,11 @@
import { PropsWithChildren } from 'react'
import { useUser } from '../app/user/userStore.ts'
import { useUser } from '../app/user/user.ts'
import NavButton from './buttons/NavButton.tsx'
type NavBarProps = unknown
export default function NavBar({ children }: PropsWithChildren<NavBarProps>) {
const { user } = useUser()
const user = useUser()
return (
<nav className={`w-full flex flex-row justify-end gap-4 px-4 md:px-8 py-3`}>
{children}

12
src/types.d.ts vendored
View file

@ -1,3 +1,5 @@
import { User } from './app/user/user.ts'
export interface ProblemDetails {
detail: string
title: string
@ -5,3 +7,13 @@ export interface ProblemDetails {
type: string
traceId: string
}
declare global {
interface Window {
$femto: FemtoApp
}
}
export interface FemtoApp {
user: User | null
}

View file

@ -0,0 +1,28 @@
import { useEffect } from 'react'
import { useUser } from './app/user/user.ts'
import { AuthService } from './app/auth/authService.ts'
// Starts a loop that pings the server to keep the session alive, while also getting any updates on the user profile
export function useRefreshSessionLoop(authService: AuthService) {
const user = useUser()
const userId = user?.userId ?? null
useEffect(() => {
if (userId == null) {
return
}
const timeouts: number[] = []
timeouts.push(
setTimeout(async function refreshUser() {
await authService.refreshUser(userId)
timeouts.push(setTimeout(refreshUser, 60_000))
}),
)
return () => {
timeouts.forEach(clearTimeout)
}
}, [authService, userId])
}