Implement automatic Garmin token refresh and fix expiry tracking
- Add OAuth1 to OAuth2 token exchange using Garmin's exchange endpoint - Track refresh token expiry (~30 days) instead of access token expiry (~21 hours) - Auto-refresh access tokens in cron sync before they expire - Update Python script to output refresh_token_expires_at - Add garminRefreshTokenExpiresAt field to User type and database schema - Fix token input UX: show when warning active, not just when disconnected - Add Cache-Control headers to /api/user and /api/garmin/status to prevent stale data - Add oauth-1.0a package for OAuth1 signature generation The system now automatically refreshes OAuth2 tokens using the stored OAuth1 token, so users only need to re-run the Python auth script every ~30 days (when refresh token expires) instead of every ~21 hours (when access token expires). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,7 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "valid-calendar-token-abc123def",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("POST /api/calendar/regenerate-token", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "old-calendar-token-abc123",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ABOUTME: Unit tests for Garmin sync cron endpoint.
|
||||
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { User } from "@/types";
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { User } from "@/types";
|
||||
let mockUsers: User[] = [];
|
||||
// Track DailyLog creations
|
||||
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
|
||||
// Track user updates
|
||||
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||
|
||||
// Mock PocketBase
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
@@ -20,6 +22,7 @@ vi.mock("@/lib/pocketbase", () => ({
|
||||
return [];
|
||||
}),
|
||||
create: mockPbCreate,
|
||||
update: mockPbUpdate,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
@@ -28,7 +31,14 @@ vi.mock("@/lib/pocketbase", () => ({
|
||||
const mockDecrypt = vi.fn((ciphertext: string) => {
|
||||
// Return mock OAuth2 token JSON
|
||||
if (ciphertext.includes("oauth2")) {
|
||||
return JSON.stringify({ accessToken: "mock-token-123" });
|
||||
return JSON.stringify({ access_token: "mock-token-123" });
|
||||
}
|
||||
// Return mock OAuth1 token JSON (needed for refresh flow)
|
||||
if (ciphertext.includes("oauth1")) {
|
||||
return JSON.stringify({
|
||||
oauth_token: "mock-oauth1-token",
|
||||
oauth_token_secret: "mock-oauth1-secret",
|
||||
});
|
||||
}
|
||||
return ciphertext.replace("encrypted:", "");
|
||||
});
|
||||
@@ -57,10 +67,15 @@ vi.mock("@/lib/garmin", () => ({
|
||||
|
||||
// Mock email sending
|
||||
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@/lib/email", () => ({
|
||||
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||
mockSendTokenExpirationWarning(...args),
|
||||
sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args),
|
||||
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
||||
mockSendPeriodConfirmationEmail(...args),
|
||||
}));
|
||||
|
||||
// Mock logger (required for route to run without side effects)
|
||||
@@ -87,6 +102,7 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
garminOauth1Token: "encrypted:oauth1-token",
|
||||
garminOauth2Token: "encrypted:oauth2-token",
|
||||
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 28,
|
||||
@@ -112,8 +128,10 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mockUsers = [];
|
||||
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
|
||||
mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation
|
||||
process.env.CRON_SECRET = validSecret;
|
||||
});
|
||||
|
||||
@@ -188,9 +206,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
|
||||
});
|
||||
|
||||
it("skips users with expired tokens", async () => {
|
||||
mockIsTokenExpired.mockReturnValue(true);
|
||||
mockUsers = [createMockUser()];
|
||||
it("skips users with expired refresh tokens", async () => {
|
||||
// Set refresh token to expired (in the past)
|
||||
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
||||
];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -415,9 +436,28 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
});
|
||||
|
||||
describe("Token expiration warnings", () => {
|
||||
it("sends warning email when token expires in exactly 14 days", async () => {
|
||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||
mockDaysUntilExpiry.mockReturnValue(14);
|
||||
// Use fake timers to ensure consistent date calculations
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Helper to create a date N days from now
|
||||
function daysFromNow(days: number): Date {
|
||||
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
it("sends warning email when refresh token expires in exactly 14 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
email: "user@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||
}),
|
||||
];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -430,9 +470,13 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.warningsSent).toBe(1);
|
||||
});
|
||||
|
||||
it("sends warning email when token expires in exactly 7 days", async () => {
|
||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||
mockDaysUntilExpiry.mockReturnValue(7);
|
||||
it("sends warning email when refresh token expires in exactly 7 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
email: "user@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(7),
|
||||
}),
|
||||
];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -445,36 +489,40 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.warningsSent).toBe(1);
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 30 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(30);
|
||||
it("does not send warning when refresh token expires in 30 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 15 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(15);
|
||||
it("does not send warning when refresh token expires in 15 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 8 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(8);
|
||||
it("does not send warning when refresh token expires in 8 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 6 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(6);
|
||||
it("does not send warning when refresh token expires in 6 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(6) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -483,11 +531,17 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
|
||||
it("sends warnings for multiple users on different thresholds", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ id: "user1", email: "user1@example.com" }),
|
||||
createMockUser({ id: "user2", email: "user2@example.com" }),
|
||||
createMockUser({
|
||||
id: "user1",
|
||||
email: "user1@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||
}),
|
||||
createMockUser({
|
||||
id: "user2",
|
||||
email: "user2@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(7),
|
||||
}),
|
||||
];
|
||||
// First user at 14 days, second user at 7 days
|
||||
mockDaysUntilExpiry.mockReturnValueOnce(14).mockReturnValueOnce(7);
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -507,8 +561,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
});
|
||||
|
||||
it("continues processing sync even if warning email fails", async () => {
|
||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||
mockDaysUntilExpiry.mockReturnValue(14);
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
email: "user@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||
}),
|
||||
];
|
||||
mockSendTokenExpirationWarning.mockRejectedValueOnce(
|
||||
new Error("Email failed"),
|
||||
);
|
||||
@@ -520,10 +578,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.usersProcessed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not send warning for expired tokens", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockIsTokenExpired.mockReturnValue(true);
|
||||
mockDaysUntilExpiry.mockReturnValue(-1);
|
||||
it("does not send warning for expired refresh tokens", async () => {
|
||||
// Expired refresh tokens are skipped entirely (not synced), so no warning
|
||||
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
|
||||
@@ -5,14 +5,17 @@ import { NextResponse } from "next/server";
|
||||
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
|
||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||
import { sendTokenExpirationWarning } from "@/lib/email";
|
||||
import { decrypt } from "@/lib/encryption";
|
||||
import { decrypt, encrypt } from "@/lib/encryption";
|
||||
import {
|
||||
daysUntilExpiry,
|
||||
fetchBodyBattery,
|
||||
fetchHrvStatus,
|
||||
fetchIntensityMinutes,
|
||||
isTokenExpired,
|
||||
} from "@/lib/garmin";
|
||||
import {
|
||||
exchangeOAuth1ForOAuth2,
|
||||
isAccessTokenExpired,
|
||||
type OAuth1TokenData,
|
||||
} from "@/lib/garmin-auth";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
activeUsersGauge,
|
||||
@@ -20,13 +23,14 @@ import {
|
||||
garminSyncTotal,
|
||||
} from "@/lib/metrics";
|
||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||
import type { GarminTokens, User } from "@/types";
|
||||
import type { User } from "@/types";
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean;
|
||||
usersProcessed: number;
|
||||
errors: number;
|
||||
skippedExpired: number;
|
||||
tokensRefreshed: number;
|
||||
warningsSent: number;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -47,6 +51,7 @@ export async function POST(request: Request) {
|
||||
usersProcessed: 0,
|
||||
errors: 0,
|
||||
skippedExpired: 0,
|
||||
tokensRefreshed: 0,
|
||||
warningsSent: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
@@ -66,38 +71,81 @@ export async function POST(request: Request) {
|
||||
const userSyncStartTime = Date.now();
|
||||
|
||||
try {
|
||||
// Check if tokens are expired
|
||||
// Check if refresh token is expired (user needs to re-auth via Python script)
|
||||
// Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: user.garminOauth1Token,
|
||||
oauth2: user.garminOauth2Token,
|
||||
// biome-ignore lint/style/noNonNullAssertion: filtered above
|
||||
expires_at: user.garminTokenExpiresAt!.toISOString(),
|
||||
};
|
||||
|
||||
if (isTokenExpired(tokens)) {
|
||||
result.skippedExpired++;
|
||||
continue;
|
||||
if (user.garminRefreshTokenExpiresAt) {
|
||||
const refreshTokenExpired =
|
||||
new Date(user.garminRefreshTokenExpiresAt) <= new Date();
|
||||
if (refreshTokenExpired) {
|
||||
logger.info(
|
||||
{ userId: user.id },
|
||||
"Refresh token expired, skipping user",
|
||||
);
|
||||
result.skippedExpired++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Log sync start
|
||||
logger.info({ userId: user.id }, "Garmin sync start");
|
||||
|
||||
// Check for token expiration warnings (exactly 14 or 7 days)
|
||||
const daysRemaining = daysUntilExpiry(tokens);
|
||||
if (daysRemaining === 14 || daysRemaining === 7) {
|
||||
try {
|
||||
await sendTokenExpirationWarning(user.email, daysRemaining, user.id);
|
||||
result.warningsSent++;
|
||||
} catch {
|
||||
// Continue processing even if warning email fails
|
||||
// Check for refresh token expiration warnings (exactly 14 or 7 days)
|
||||
if (user.garminRefreshTokenExpiresAt) {
|
||||
const refreshExpiry = new Date(user.garminRefreshTokenExpiresAt);
|
||||
const now = new Date();
|
||||
const diffMs = refreshExpiry.getTime() - now.getTime();
|
||||
const daysRemaining = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (daysRemaining === 14 || daysRemaining === 7) {
|
||||
try {
|
||||
await sendTokenExpirationWarning(
|
||||
user.email,
|
||||
daysRemaining,
|
||||
user.id,
|
||||
);
|
||||
result.warningsSent++;
|
||||
} catch {
|
||||
// Continue processing even if warning email fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt OAuth2 token
|
||||
// Decrypt tokens
|
||||
const oauth1Json = decrypt(user.garminOauth1Token);
|
||||
const oauth1Data = JSON.parse(oauth1Json) as OAuth1TokenData;
|
||||
const oauth2Json = decrypt(user.garminOauth2Token);
|
||||
const oauth2Data = JSON.parse(oauth2Json);
|
||||
const accessToken = oauth2Data.accessToken;
|
||||
let oauth2Data = JSON.parse(oauth2Json);
|
||||
|
||||
// Check if access token needs refresh
|
||||
// biome-ignore lint/style/noNonNullAssertion: filtered above
|
||||
const accessTokenExpiresAt = user.garminTokenExpiresAt!;
|
||||
if (isAccessTokenExpired(accessTokenExpiresAt)) {
|
||||
logger.info({ userId: user.id }, "Access token expired, refreshing");
|
||||
try {
|
||||
const refreshResult = await exchangeOAuth1ForOAuth2(oauth1Data);
|
||||
oauth2Data = refreshResult.oauth2;
|
||||
|
||||
// Update stored tokens
|
||||
const encryptedOauth2 = encrypt(JSON.stringify(oauth2Data));
|
||||
await pb.collection("users").update(user.id, {
|
||||
garminOauth2Token: encryptedOauth2,
|
||||
garminTokenExpiresAt: refreshResult.expires_at,
|
||||
garminRefreshTokenExpiresAt: refreshResult.refresh_token_expires_at,
|
||||
});
|
||||
|
||||
result.tokensRefreshed++;
|
||||
logger.info({ userId: user.id }, "Access token refreshed");
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
{ userId: user.id, err: refreshError },
|
||||
"Failed to refresh access token",
|
||||
);
|
||||
result.errors++;
|
||||
garminSyncTotal.inc({ status: "failure" });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const accessToken = oauth2Data.access_token;
|
||||
|
||||
// Fetch Garmin data
|
||||
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([
|
||||
|
||||
@@ -29,9 +29,15 @@ vi.mock("@/lib/pocketbase", () => ({
|
||||
|
||||
// Mock email sending
|
||||
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@/lib/email", () => ({
|
||||
sendDailyEmail: (data: unknown) => mockSendDailyEmail(data),
|
||||
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||
mockSendTokenExpirationWarning(...args),
|
||||
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
||||
mockSendPeriodConfirmationEmail(...args),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
@@ -48,6 +54,7 @@ describe("POST /api/cron/notifications", () => {
|
||||
garminOauth1Token: "encrypted:oauth1-token",
|
||||
garminOauth2Token: "encrypted:oauth2-token",
|
||||
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("GET /api/cycle/current", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("POST /api/cycle/period", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2024-12-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -71,6 +71,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -100,6 +101,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -129,6 +131,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -156,6 +159,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -185,6 +189,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: pastDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -216,6 +221,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -245,6 +251,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -274,6 +281,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -303,6 +311,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -329,6 +338,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -12,26 +12,41 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
const connected = freshUser.garminConnected === true;
|
||||
|
||||
if (!connected) {
|
||||
return NextResponse.json({
|
||||
connected: false,
|
||||
daysUntilExpiry: null,
|
||||
expired: false,
|
||||
warningLevel: null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
connected: false,
|
||||
daysUntilExpiry: null,
|
||||
expired: false,
|
||||
warningLevel: null,
|
||||
},
|
||||
{
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = freshUser.garminTokenExpiresAt
|
||||
// Use refresh token expiry for user-facing warnings (when they need to re-auth)
|
||||
// Fall back to access token expiry if refresh expiry not set
|
||||
const refreshTokenExpiresAt = freshUser.garminRefreshTokenExpiresAt
|
||||
? String(freshUser.garminRefreshTokenExpiresAt)
|
||||
: "";
|
||||
const accessTokenExpiresAt = freshUser.garminTokenExpiresAt
|
||||
? String(freshUser.garminTokenExpiresAt)
|
||||
: "";
|
||||
|
||||
const tokens = {
|
||||
oauth1: "",
|
||||
oauth2: "",
|
||||
expires_at: expiresAt,
|
||||
expires_at: accessTokenExpiresAt,
|
||||
refresh_token_expires_at: refreshTokenExpiresAt || undefined,
|
||||
};
|
||||
|
||||
const days = daysUntilExpiry(tokens);
|
||||
const expired = isTokenExpired(tokens);
|
||||
|
||||
// Check if refresh token is expired (user needs to re-authenticate)
|
||||
const expired = refreshTokenExpiresAt
|
||||
? new Date(refreshTokenExpiresAt) <= new Date()
|
||||
: isTokenExpired(tokens);
|
||||
|
||||
let warningLevel: "warning" | "critical" | null = null;
|
||||
if (days <= 7) {
|
||||
@@ -40,10 +55,15 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
warningLevel = "warning";
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connected: true,
|
||||
daysUntilExpiry: days,
|
||||
expired,
|
||||
warningLevel,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
connected: true,
|
||||
daysUntilExpiry: days,
|
||||
expired,
|
||||
warningLevel,
|
||||
},
|
||||
{
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ describe("POST /api/garmin/tokens", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -141,6 +142,7 @@ describe("POST /api/garmin/tokens", () => {
|
||||
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
|
||||
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
|
||||
garminTokenExpiresAt: expiresAt,
|
||||
garminRefreshTokenExpiresAt: expect.any(String),
|
||||
garminConnected: true,
|
||||
});
|
||||
});
|
||||
@@ -267,6 +269,7 @@ describe("DELETE /api/garmin/tokens", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -304,6 +307,7 @@ describe("DELETE /api/garmin/tokens", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: null,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
garminConnected: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { logger } from "@/lib/logger";
|
||||
|
||||
export const POST = withAuth(async (request, user, pb) => {
|
||||
const body = await request.json();
|
||||
const { oauth1, oauth2, expires_at } = body;
|
||||
const { oauth1, oauth2, expires_at, refresh_token_expires_at } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!oauth1) {
|
||||
@@ -52,6 +52,23 @@ export const POST = withAuth(async (request, user, pb) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate refresh_token_expires_at if provided
|
||||
let refreshTokenExpiresAt = refresh_token_expires_at;
|
||||
if (refreshTokenExpiresAt) {
|
||||
const refreshExpiryDate = new Date(refreshTokenExpiresAt);
|
||||
if (Number.isNaN(refreshExpiryDate.getTime())) {
|
||||
return NextResponse.json(
|
||||
{ error: "refresh_token_expires_at must be a valid date" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If not provided, estimate refresh token expiry as ~30 days from now
|
||||
refreshTokenExpiresAt = new Date(
|
||||
Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
// Encrypt tokens before storing
|
||||
const encryptedOauth1 = encrypt(JSON.stringify(oauth1));
|
||||
const encryptedOauth2 = encrypt(JSON.stringify(oauth2));
|
||||
@@ -61,6 +78,7 @@ export const POST = withAuth(async (request, user, pb) => {
|
||||
garminOauth1Token: encryptedOauth1,
|
||||
garminOauth2Token: encryptedOauth2,
|
||||
garminTokenExpiresAt: expires_at,
|
||||
garminRefreshTokenExpiresAt: refreshTokenExpiresAt,
|
||||
garminConnected: true,
|
||||
});
|
||||
|
||||
@@ -84,11 +102,12 @@ export const POST = withAuth(async (request, user, pb) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate days until expiry
|
||||
// Calculate days until refresh token expiry (what users care about)
|
||||
const expiryDays = daysUntilExpiry({
|
||||
oauth1: "",
|
||||
oauth2: "",
|
||||
expires_at,
|
||||
refresh_token_expires_at: refreshTokenExpiresAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -103,6 +122,7 @@ export const DELETE = withAuth(async (_request, user, pb) => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: null,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
garminConnected: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("GET /api/history", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("POST /api/overrides", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -187,6 +188,7 @@ describe("DELETE /api/overrides", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("GET /api/period-history", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("PATCH /api/period-logs/[id]", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -276,6 +277,7 @@ describe("DELETE /api/period-logs/[id]", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -48,6 +48,7 @@ describe("GET /api/today", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("GET /api/user", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -138,6 +139,7 @@ describe("PATCH /api/user", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -26,16 +26,21 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0]
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
id: freshUser.id,
|
||||
email: freshUser.email,
|
||||
garminConnected: freshUser.garminConnected ?? false,
|
||||
cycleLength: freshUser.cycleLength,
|
||||
lastPeriodDate,
|
||||
notificationTime: freshUser.notificationTime,
|
||||
timezone: freshUser.timezone,
|
||||
activeOverrides: freshUser.activeOverrides ?? [],
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: freshUser.id,
|
||||
email: freshUser.email,
|
||||
garminConnected: freshUser.garminConnected ?? false,
|
||||
cycleLength: freshUser.cycleLength,
|
||||
lastPeriodDate,
|
||||
notificationTime: freshUser.notificationTime,
|
||||
timezone: freshUser.timezone,
|
||||
activeOverrides: freshUser.activeOverrides ?? [],
|
||||
},
|
||||
{
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,7 +139,12 @@ export default function GarminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const showTokenInput = !status?.connected || status?.expired;
|
||||
// Show token input when:
|
||||
// - Not connected
|
||||
// - Token expired
|
||||
// - Warning level active (so user can proactively paste new tokens)
|
||||
const showTokenInput =
|
||||
!status?.connected || status?.expired || status?.warningLevel;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -242,7 +247,11 @@ export default function GarminSettingsPage() {
|
||||
{/* Token Input Section */}
|
||||
{showTokenInput && (
|
||||
<div className="border border-input rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Connect Garmin</h2>
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{status?.connected && status?.warningLevel
|
||||
? "Refresh Tokens"
|
||||
: "Connect Garmin"}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-400 px-4 py-3 rounded text-sm">
|
||||
|
||||
Reference in New Issue
Block a user