Skip to content

Commit 40269ad

Browse files
committed
feat: refresh token
1 parent 5c6e837 commit 40269ad

File tree

9 files changed

+165
-27
lines changed

9 files changed

+165
-27
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ FIREBASE_AUTH_DOMAIN=
66
FIREBASE_PROJECT_ID=
77

88
SESSION_COOKIE_NAME=
9+
REFRESH_TOKEN_COOKIE_NAME=
910
LIBRARY_COOKIE_NAME=
1011
GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json
1112

components/borrows/TabLink.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const TabLink = <T extends string>({
1515
return (
1616
<div
1717
className={cn(
18-
'inline-flex gap-2 w-fit items-center justify-center rounded-lg bg-muted text-muted-foreground p-[3px] flex-wrap',
18+
'inline-flex gap-2 w-fit items-center justify-center rounded-lg bg-muted text-muted-foreground p-0.75 flex-wrap',
1919
className
2020
)}
2121
>

components/dashboard/AnalysisChartsWrapper.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { MostBorrowedBookChart } from './MostBorrowedBookChart'
33
import { MontlyBorrowChart } from './MontlyBorrowChart'
44
import { TopMembershipChart } from './TopMembershipChart'
55
import { getAnalysis } from '@/lib/api/analysis'
6+
import { format } from 'date-fns'
67

78
type AnalysisChartsWrapperProps = {
89
libraryId: string
@@ -37,12 +38,18 @@ export async function AnalysisChartsWrapper({
3738
)
3839
}
3940

41+
const formattedFrom = format(from, 'LLL dd, yyyy')
42+
const formattedTo = format(to, 'LLL dd, yyyy')
4043
return (
4144
<>
4245
<MostBorrowedBookChart data={res.data.book} />
4346
<TopMembershipChart data={res.data.membership} />
4447
<MontlyBorrowChart data={res.data.borrowing} />
45-
<MonthlyRevenueChart data={res.data.revenue} />
48+
<MonthlyRevenueChart
49+
data={res.data.revenue}
50+
from={formattedFrom}
51+
to={formattedTo}
52+
/>
4653
</>
4754
)
4855
}

components/dashboard/HeatmapChart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export const HeatmapChart: React.FC<HeatmapChartProps> = ({
9999
return {
100100
x: decimalHour,
101101
y: localDayOfWeek,
102-
z: count * 20, // Scale up for bubble size
102+
z: count, // Scale up for bubble size
103103
count,
104104
day: dayNames[localDayOfWeek],
105105
time: formatTime(localHour, localMinute),

components/dashboard/MonthlyRevenueChart.tsx

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use client'
22

3-
// import { TrendingUp } from 'lucide-react'
43
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts'
54

65
import {
@@ -19,9 +18,6 @@ import {
1918
} from '@/components/ui/chart'
2019
import { Analysis } from '@/lib/types/analysis'
2120
import { formatDate } from '@/lib/utils'
22-
import { useSearchParams } from 'next/navigation'
23-
import { format, parse } from 'date-fns'
24-
import { useMemo } from 'react'
2521

2622
const chartConfig = {
2723
subscription: {
@@ -34,20 +30,15 @@ const chartConfig = {
3430
},
3531
} satisfies ChartConfig
3632

37-
export function MonthlyRevenueChart({ data }: { data: Analysis['revenue'] }) {
38-
const params = useSearchParams()
39-
const paramFrom = params.get('from') as string
40-
const paramTo = params.get('to') as string
41-
42-
const [from, to] = useMemo(() => {
43-
const from = format(
44-
parse(paramFrom, 'dd-MM-yyyy', new Date()),
45-
'LLL dd, yyyy'
46-
)
47-
const to = format(parse(paramTo, 'dd-MM-yyyy', new Date()), 'LLL dd, yyyy')
48-
return [from, to]
49-
}, [paramFrom, paramTo])
50-
33+
export function MonthlyRevenueChart({
34+
data,
35+
from,
36+
to,
37+
}: {
38+
data: Analysis['revenue']
39+
from: string
40+
to: string
41+
}) {
5142
return (
5243
<Card>
5344
<CardHeader>
@@ -111,15 +102,13 @@ export function MonthlyRevenueChart({ data }: { data: Analysis['revenue'] }) {
111102
fill="url(#fillFine)"
112103
fillOpacity={0.4}
113104
stroke="var(--color-fine)"
114-
stackId="a"
115105
/>
116106
<Area
117107
dataKey="subscription"
118108
type="natural"
119109
fill="url(#fillSubscription)"
120110
fillOpacity={0.4}
121111
stroke="var(--color-subscription)"
122-
stackId="a"
123112
/>
124113
</AreaChart>
125114
</ChartContainer>

components/dashboard/MontlyBorrowChart.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export function MontlyBorrowChart({ data }: { data: Analysis['borrowing'] }) {
8383
dataKey="total_borrow"
8484
type="natural"
8585
stroke="var(--color-total_borrow)"
86-
strokeWidth={2}
86+
strokeWidth={1}
8787
dot={false}
8888
activeDot={{
8989
r: 6,
@@ -93,7 +93,7 @@ export function MontlyBorrowChart({ data }: { data: Analysis['borrowing'] }) {
9393
dataKey="total_return"
9494
type="natural"
9595
stroke="var(--color-total_return)"
96-
strokeWidth={2}
96+
strokeWidth={1}
9797
dot={false}
9898
activeDot={{
9999
r: 6,

lib/actions/login.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,25 @@ export async function loginAction(
4242

4343
const user = userCredentials.user
4444
const sessionName = process.env.SESSION_COOKIE_NAME as string
45+
const refreshTokenName = process.env.REFRESH_TOKEN_COOKIE_NAME as string
4546
const result = await user.getIdTokenResult()
4647
const token = result.token
48+
const refreshToken = userCredentials.user.refreshToken
4749
const maxAge =
4850
new Date(result.expirationTime).getTime() - new Date().getTime()
4951
const cookieStore = await cookies()
52+
53+
// Store ID token
5054
cookieStore.set(sessionName, token, {
51-
maxAge,
55+
maxAge: Math.floor(maxAge / 1000), // Convert milliseconds to seconds
56+
httpOnly: true,
57+
secure: true,
58+
sameSite: 'strict',
59+
})
60+
61+
// Store refresh token (longer expiration)
62+
cookieStore.set(refreshTokenName, refreshToken, {
63+
maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
5264
httpOnly: true,
5365
secure: true,
5466
sameSite: 'strict',

lib/actions/logout.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ import { cookies } from 'next/headers'
55
export async function logoutAction() {
66
const cookieStore = await cookies()
77
const sessionName = process.env.SESSION_COOKIE_NAME as string
8+
const refreshTokenName = process.env.REFRESH_TOKEN_COOKIE_NAME as string
89
cookieStore.delete(sessionName)
10+
cookieStore.delete(refreshTokenName)
911
}

proxy.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,135 @@
11
import { NextResponse } from 'next/server'
22
import { NextRequest, ProxyConfig } from 'next/server'
3+
import { getAuth } from 'firebase-admin/auth'
4+
import { adminApp } from './lib/firebase/admin'
5+
6+
const auth = getAuth(adminApp)
37

48
export async function proxy(request: NextRequest) {
5-
if (!request.cookies.has(process.env.SESSION_COOKIE_NAME!)) {
9+
const sessionName = process.env.SESSION_COOKIE_NAME!
10+
const refreshTokenName = process.env.REFRESH_TOKEN_COOKIE_NAME!
11+
const refreshToken = request.cookies.get(refreshTokenName)?.value
12+
const idToken = request.cookies.get(sessionName)?.value
13+
14+
// If neither token exists, redirect to login
15+
if (!idToken && !refreshToken) {
16+
const loginUrl = new URL('/login', request.url)
17+
loginUrl.searchParams.set('from', request.nextUrl.pathname)
18+
return NextResponse.redirect(loginUrl)
19+
}
20+
21+
// If ID token is missing but refresh token exists, try to refresh
22+
if (!idToken && refreshToken) {
23+
try {
24+
console.log('proxy: ID token missing, refreshing with refresh token')
25+
const refreshResponse = await fetch(
26+
`https://securetoken.googleapis.com/v1/token?key=${process.env.FIREBASE_API_KEY}`,
27+
{
28+
method: 'POST',
29+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
30+
body: new URLSearchParams({
31+
grant_type: 'refresh_token',
32+
refresh_token: refreshToken,
33+
}),
34+
}
35+
)
36+
37+
if (!refreshResponse.ok) {
38+
throw new Error('Failed to refresh token')
39+
}
40+
41+
const data = await refreshResponse.json()
42+
const newIdToken = data.id_token
43+
const newRefreshToken = data.refresh_token
44+
45+
const decodedToken = await auth.verifyIdToken(newIdToken)
46+
const expiresIn = decodedToken.exp * 1000 - Date.now()
47+
48+
const response = NextResponse.next()
49+
50+
response.cookies.set(sessionName, newIdToken, {
51+
maxAge: Math.floor(expiresIn / 1000),
52+
httpOnly: true,
53+
secure: true,
54+
sameSite: 'strict',
55+
})
56+
57+
response.cookies.set(refreshTokenName, newRefreshToken, {
58+
maxAge: 30 * 24 * 60 * 60, // 30 days
59+
httpOnly: true,
60+
secure: true,
61+
sameSite: 'strict',
62+
})
63+
64+
return response
65+
} catch (refreshError) {
66+
console.error('Token refresh failed:', refreshError)
67+
const loginUrl = new URL('/login', request.url)
68+
loginUrl.searchParams.set('from', request.nextUrl.pathname)
69+
return NextResponse.redirect(loginUrl)
70+
}
71+
}
72+
73+
// ID token exists, verify it
74+
try {
75+
await auth.verifyIdToken(idToken!)
76+
return NextResponse.next()
77+
} catch (error: any) {
78+
console.log('proxy: Token verification error:', error)
79+
80+
// Token is expired, try to refresh
81+
if (error?.code === 'auth/id-token-expired' && refreshToken) {
82+
try {
83+
console.log('proxy: ID token expired, refreshing')
84+
const refreshResponse = await fetch(
85+
`https://securetoken.googleapis.com/v1/token?key=${process.env.FIREBASE_API_KEY}`,
86+
{
87+
method: 'POST',
88+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
89+
body: new URLSearchParams({
90+
grant_type: 'refresh_token',
91+
refresh_token: refreshToken,
92+
}),
93+
}
94+
)
95+
96+
if (!refreshResponse.ok) {
97+
throw new Error('Failed to refresh token')
98+
}
99+
100+
const data = await refreshResponse.json()
101+
const newIdToken = data.id_token
102+
const newRefreshToken = data.refresh_token
103+
104+
const decodedToken = await auth.verifyIdToken(newIdToken)
105+
const expiresIn = decodedToken.exp * 1000 - Date.now()
106+
107+
const response = NextResponse.next()
108+
109+
response.cookies.set(sessionName, newIdToken, {
110+
maxAge: Math.floor(expiresIn / 1000),
111+
httpOnly: true,
112+
secure: true,
113+
sameSite: 'strict',
114+
})
115+
116+
response.cookies.set(refreshTokenName, newRefreshToken, {
117+
maxAge: 30 * 24 * 60 * 60, // 30 days
118+
httpOnly: true,
119+
secure: true,
120+
sameSite: 'strict',
121+
})
122+
123+
return response
124+
} catch (refreshError) {
125+
console.error('Token refresh failed:', refreshError)
126+
const loginUrl = new URL('/login', request.url)
127+
loginUrl.searchParams.set('from', request.nextUrl.pathname)
128+
return NextResponse.redirect(loginUrl)
129+
}
130+
}
131+
132+
// Token is revoked or invalid, redirect to login
6133
const loginUrl = new URL('/login', request.url)
7134
loginUrl.searchParams.set('from', request.nextUrl.pathname)
8135
return NextResponse.redirect(loginUrl)

0 commit comments

Comments
 (0)