diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..324637b --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + mongo.4 + true + com.dbschema.MongoJdbcDriver + mongodb://localhost:27017/?authSource=admin + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/db-forest-config.xml b/.idea/db-forest-config.xml index 612e11a..f5db96b 100644 --- a/.idea/db-forest-config.xml +++ b/.idea/db-forest-config.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/app/ai/actions.ts b/app/ai/actions.ts index 9d0b617..324df8a 100644 --- a/app/ai/actions.ts +++ b/app/ai/actions.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache"; import { generateAnalysis, generateDashboardAnalysis } from "@/lib/ai/claude"; +import { getCurrentUserId } from "@/lib/session"; import type { AiAnalysisTargetType } from "@/lib/models/analysis"; export type GenerateAnalysisState = { error: string } | { success: true } | null; @@ -10,8 +11,9 @@ export async function generateAnalysisAction( targetType: AiAnalysisTargetType, targetId: string ): Promise { + const userId = await getCurrentUserId(); try { - await generateAnalysis(targetType, targetId); + await generateAnalysis(userId, targetType, targetId); } catch (error) { return { error: error instanceof Error ? error.message : "Nie udało się wygenerować analizy." }; } @@ -22,8 +24,9 @@ export async function generateAnalysisAction( } export async function generateDashboardAnalysisAction(): Promise { + const userId = await getCurrentUserId(); try { - await generateDashboardAnalysis(); + await generateDashboardAnalysis(userId); } catch (error) { return { error: error instanceof Error ? error.message : "Nie udało się wygenerować analizy." }; } diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..86c9f3d --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/app/layout.tsx b/app/layout.tsx index b3c0654..7b1677f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Nav } from "@/components/nav"; +import { auth } from "@/auth"; import "./globals.css"; const geistSans = Geist({ @@ -18,18 +19,20 @@ export const metadata: Metadata = { description: "Analiza treningów biegowych i siłowych", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const session = await auth(); + return ( - + {session && } {children} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..0567980 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,36 @@ +import { signIn } from "@/auth"; + +export default function LoginPage() { + return ( + + + {/* eslint-disable-next-line @next/next/no-img-element */} + + KNUR + + Książka Notowań Udźwigów i Rezultatów + + + + { + "use server"; + await signIn("keycloak", { redirectTo: "/" }); + }} + > + + Zaloguj + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx index 0f4642e..93fd473 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,14 +8,17 @@ import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/l import { getDashboardAnalysis, serializeAnalysis } from "@/lib/models/analysis"; import { listRunningActivities } from "@/lib/models/running"; import { listStrengthWorkouts } from "@/lib/models/strength"; +import { getCurrentUserId } from "@/lib/session"; export const dynamic = "force-dynamic"; export default async function Home() { + const userId = await getCurrentUserId(); + const [runs, strengthWorkouts, dashboardAnalysis] = await Promise.all([ - listRunningActivities(), - listStrengthWorkouts(), - getDashboardAnalysis(), + listRunningActivities(userId), + listStrengthWorkouts(userId), + getDashboardAnalysis(userId), ]); const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); diff --git a/app/running/[id]/page.tsx b/app/running/[id]/page.tsx index 1ecd352..7aa7582 100644 --- a/app/running/[id]/page.tsx +++ b/app/running/[id]/page.tsx @@ -20,24 +20,29 @@ import { type RunMetrics, type RunningActivity, } from "@/lib/models/running"; +import { getCurrentUserId } from "@/lib/session"; export const dynamic = "force-dynamic"; -// hasRoute may be undefined for activities synced before the field was added, -// even if routePoints are already in the DB. function mayHaveRoute(activity: RunningActivity): boolean { return Boolean(activity.hasRoute) || Boolean(activity.routePoints?.length); } -async function RouteMapFetcher({ activity }: { activity: RunningActivity }) { +async function RouteMapFetcher({ + activity, + userId, +}: { + activity: RunningActivity; + userId: string; +}) { let routePoints = activity.routePoints; if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) { try { - const client = await getAuthorizedClient(); + const client = await getAuthorizedClient(userId); const result = await fetchActivityRoutePoints(client, activity.garminActivityId); if (result) { - await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile); + await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile); routePoints = result.points; } } catch { @@ -62,15 +67,21 @@ function hasValidElevation(profile: number[] | undefined): boolean { return Array.isArray(profile) && profile.some((v) => v > 0); } -async function ElevationFetcher({ activity }: { activity: RunningActivity }) { +async function ElevationFetcher({ + activity, + userId, +}: { + activity: RunningActivity; + userId: string; +}) { let elevationProfile = activity.elevationProfile; if (!hasValidElevation(elevationProfile) && mayHaveRoute(activity)) { try { - const client = await getAuthorizedClient(); + const client = await getAuthorizedClient(userId); const result = await fetchActivityRoutePoints(client, activity.garminActivityId); if (result) { - await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile); + await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile); elevationProfile = result.elevationProfile; } } catch { @@ -80,14 +91,23 @@ async function ElevationFetcher({ activity }: { activity: RunningActivity }) { if (!elevationProfile || elevationProfile.length < 2) return null; - const data = elevationProfile + const elevData = elevationProfile .map((altM, i) => ({ distanceKm: Math.round((i / elevationProfile!.length) * activity.distanceM / 10) / 100, altM, })) .filter((p) => p.altM > 0); - if (data.length < 2) return null; + if (elevData.length < 2) return null; + + // Merge pace by fractional position (both arrays span the same run, different sample counts) + const paceSrc = activity.runMetrics?.paceSec; + const data = elevData.map((ep, i) => { + if (!paceSrc || paceSrc.length === 0) return ep; + const pi = Math.min(Math.round((i / elevData.length) * paceSrc.length), paceSrc.length - 1); + const v = paceSrc[pi]; + return { ...ep, paceSec: v > 0 && v < 1800 ? v : undefined }; + }); return ; } @@ -102,18 +122,27 @@ function toChartData( .filter((p) => p.value > 0); } -async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) { +async function RunMetricsFetcher({ + activity, + userId, +}: { + activity: RunningActivity; + userId: string; +}) { let metrics: RunMetrics | undefined = activity.runMetrics; const missingCadence = activity.avgCadence && !metrics?.cadenceSpm; const missingGcb = activity.avgGroundContactBalanceLeftPct && !metrics?.gcbLeftPct; + // Re-fetch if paceSec missing or was computed from integer-rounded speeds (< 15 unique values = rounding artifact) + const validPace = metrics?.paceSec?.filter((v) => v > 0) ?? []; + const missingPace = validPace.length === 0 || new Set(validPace).size < 15; - if ((!metrics || missingCadence || missingGcb) && mayHaveRoute(activity)) { + if ((!metrics || missingCadence || missingGcb || missingPace) && mayHaveRoute(activity)) { try { - const client = await getAuthorizedClient(); + const client = await getAuthorizedClient(userId); const fetched = await fetchActivityRunMetrics(client, activity.garminActivityId); if (fetched) { - await setRunningActivityMetrics(activity.garminActivityId, fetched); + await setRunningActivityMetrics(userId, activity.garminActivityId, fetched); metrics = fetched; } } catch { @@ -125,7 +154,6 @@ async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) { const { hrBpm, gcbLeftPct } = metrics; - // Fall back to evenly-spaced distances if Garmin didn't provide them const maxDist = Math.max(...metrics.distanceKm); const distanceKm = maxDist > 0 @@ -151,12 +179,7 @@ async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) { return ( {hrData.length > 1 && ( - + )} {gcbData.length > 1 && } @@ -175,13 +198,14 @@ export default async function RunningActivityPage({ params: Promise<{ id: string }>; }) { const { id } = await params; - const activity = await getRunningActivity(id); + const userId = await getCurrentUserId(); + const activity = await getRunningActivity(userId, id); if (!activity) { notFound(); } - const analysis = await getLatestAnalysisForTarget("running", activity._id); + const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id); return ( @@ -192,7 +216,7 @@ export default async function RunningActivityPage({ }> - + @@ -238,14 +262,18 @@ export default async function RunningActivityPage({ - + - + - + ); } diff --git a/app/running/actions.ts b/app/running/actions.ts index 72c6d4c..e1a4a65 100644 --- a/app/running/actions.ts +++ b/app/running/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import type { GarminConnect } from "garmin-connect"; import { GarminLoginRequiredError, + GarminCredentialsMissingError, beginGarminLogin, completeGarminMfaLogin, fetchActivityRoutePoints, @@ -23,19 +24,20 @@ import { saveOauth1Token, savePendingMfaState, } from "@/lib/models/garmin-auth"; +import { getCurrentUserId } from "@/lib/session"; export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null; -async function syncWithClient(client: GarminConnect): Promise { - const since = await getLastSyncAt(); +async function syncWithClient(userId: string, client: GarminConnect): Promise { + const since = await getLastSyncAt(userId); const activities = await fetchRunningActivities(client); const newCount = activities.filter((activity) => !since || activity.startTime > since).length; for (const activity of activities) { - await upsertRunningActivity(activity); + await upsertRunningActivity(userId, activity); } - await setLastSyncAt(new Date()); + await setLastSyncAt(userId, new Date()); revalidatePath("/running"); revalidatePath("/settings"); @@ -45,39 +47,45 @@ async function syncWithClient(client: GarminConnect): Promise { } export async function syncGarminActivities(): Promise { + const userId = await getCurrentUserId(); + try { - const client = await getAuthorizedClient(); - return await syncWithClient(client); + const client = await getAuthorizedClient(userId); + return await syncWithClient(userId, client); } catch (error) { + if (error instanceof GarminCredentialsMissingError) { + return { error: error.message }; + } if (!(error instanceof GarminLoginRequiredError)) { return { error: error instanceof Error ? error.message : "Synchronizacja z Garmin nie powiodła się." }; } } try { - const result = await beginGarminLogin(); + const result = await beginGarminLogin(userId); if ("mfaRequired" in result) { - await savePendingMfaState(result.pendingState); + await savePendingMfaState(userId, result.pendingState); return { mfaRequired: true }; } - await saveOauth1Token(result.oauth1Token); - return await syncWithClient(result.client); + await saveOauth1Token(userId, result.oauth1Token); + return await syncWithClient(userId, result.client); } catch (error) { return { error: error instanceof Error ? error.message : "Logowanie do Garmin nie powiodło się." }; } } export async function submitGarminMfaCode(code: string): Promise { - const pending = await getPendingMfaState(); + const userId = await getCurrentUserId(); + const pending = await getPendingMfaState(userId); if (!pending) { return { error: "Sesja logowania do Garmin wygasła. Kliknij \"Synchronizuj z Garmin\" ponownie." }; } try { const result = await completeGarminMfaLogin(pending, code); - await saveOauth1Token(result.oauth1Token); - await clearPendingMfaState(); - return await syncWithClient(result.client); + await saveOauth1Token(userId, result.oauth1Token); + await clearPendingMfaState(userId); + return await syncWithClient(userId, result.client); } catch (error) { return { error: error instanceof Error ? error.message : "Weryfikacja kodu MFA nie powiodła się." }; } @@ -86,12 +94,13 @@ export async function submitGarminMfaCode(code: string): Promise { - const activity = await getRunningActivity(activityMongoId); + const userId = await getCurrentUserId(); + const activity = await getRunningActivity(userId, activityMongoId); if (!activity) return { error: "Nie znaleziono aktywności." }; let client: GarminConnect; try { - client = await getAuthorizedClient(); + client = await getAuthorizedClient(userId); } catch { return { error: "Brak połączenia z Garmin Connect. Wykonaj synchronizację." }; } @@ -99,7 +108,7 @@ export async function loadActivityRoute(activityMongoId: string): Promise diff --git a/app/settings/actions.ts b/app/settings/actions.ts new file mode 100644 index 0000000..fcd8ef5 --- /dev/null +++ b/app/settings/actions.ts @@ -0,0 +1,28 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { saveGarminCredentials } from "@/lib/models/garmin-auth"; +import { getCurrentUserId } from "@/lib/session"; + +export type SaveGarminCredentialsState = { error: string } | { success: true } | null; + +export async function saveGarminCredentialsAction( + _prevState: SaveGarminCredentialsState, + formData: FormData +): Promise { + const email = formData.get("email"); + const password = formData.get("password"); + + if (typeof email !== "string" || !email.includes("@")) { + return { error: "Podaj prawidłowy adres e-mail." }; + } + if (typeof password !== "string" || password.length < 1) { + return { error: "Podaj hasło." }; + } + + const userId = await getCurrentUserId(); + await saveGarminCredentials(userId, email.trim(), password); + + revalidatePath("/settings"); + return { success: true }; +} diff --git a/app/settings/garmin-credentials-form.tsx b/app/settings/garmin-credentials-form.tsx new file mode 100644 index 0000000..ef6e8b6 --- /dev/null +++ b/app/settings/garmin-credentials-form.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useActionState } from "react"; +import { saveGarminCredentialsAction, type SaveGarminCredentialsState } from "./actions"; + +export function GarminCredentialsForm({ savedEmail }: { savedEmail: string | null }) { + const [state, action, pending] = useActionState( + saveGarminCredentialsAction, + null + ); + + return ( + + + + E-mail Garmin Connect + + + + + + Hasło Garmin Connect + + + + + {state && "error" in state && ( + {state.error} + )} + {state && "success" in state && ( + Zapisano dane logowania. + )} + + + {pending ? "Zapisywanie…" : "Zapisz"} + + + ); +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 0a8d40c..f81794d 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -2,22 +2,30 @@ import { CheckCircle2, XCircle } from "lucide-react"; import { SyncButton } from "@/components/sync-button"; import { formatDate } from "@/lib/format"; import { getLastSyncAt } from "@/lib/models/running"; +import { getGarminCredentials, getSavedOauth1Token } from "@/lib/models/garmin-auth"; +import { getCurrentUserId } from "@/lib/session"; +import { GarminCredentialsForm } from "./garmin-credentials-form"; export const dynamic = "force-dynamic"; -function ConfigRow({ label, configured }: { label: string; configured: boolean }) { +function StatusRow({ label, ok, okLabel = "Skonfigurowano", failLabel = "Brak konfiguracji" }: { + label: string; + ok: boolean; + okLabel?: string; + failLabel?: string; +}) { return ( {label} - {configured ? ( + {ok ? ( - Skonfigurowano + {okLabel} ) : ( - Brak w .env.local + {failLabel} )} @@ -25,31 +33,52 @@ function ConfigRow({ label, configured }: { label: string; configured: boolean } } export default async function SettingsPage() { - const lastSyncAt = await getLastSyncAt(); - - const mongoConfigured = Boolean(process.env.MONGODB_URI); - const garminConfigured = Boolean(process.env.GARMIN_EMAIL && process.env.GARMIN_PASSWORD); - const claudeConfigured = Boolean(process.env.ANTHROPIC_API_KEY); + const userId = await getCurrentUserId(); + const [lastSyncAt, garminCreds, garminToken] = await Promise.all([ + getLastSyncAt(userId), + getGarminCredentials(userId), + getSavedOauth1Token(userId), + ]); return ( Ustawienia - Status konfiguracji i synchronizacja Garmin. + Konfiguracja konta i synchronizacja Garmin. - Konfiguracja - - - + Status + + + + + + + + Konto Garmin Connect + + + Synchronizacja z Garmin - {lastSyncAt ? `Ostatnia synchronizacja: ${formatDate(lastSyncAt)}` : "Jeszcze nie zsynchronizowano"} + {lastSyncAt + ? `Ostatnia synchronizacja: ${formatDate(lastSyncAt)}` + : "Jeszcze nie zsynchronizowano"} diff --git a/app/strength/[id]/page.tsx b/app/strength/[id]/page.tsx index 5ed5124..6a07266 100644 --- a/app/strength/[id]/page.tsx +++ b/app/strength/[id]/page.tsx @@ -6,6 +6,7 @@ import { formatDate, formatDateShort } from "@/lib/format"; import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis"; import { getStrengthWorkout, listStrengthWorkouts } from "@/lib/models/strength"; import { exerciseE1rm, getExerciseHistory } from "@/lib/strength/stats"; +import { getCurrentUserId } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -17,14 +18,15 @@ export default async function StrengthWorkoutPage({ params: Promise<{ id: string }>; }) { const { id } = await params; - const workout = await getStrengthWorkout(id); + const userId = await getCurrentUserId(); + const workout = await getStrengthWorkout(userId, id); if (!workout) { notFound(); } - const analysis = await getLatestAnalysisForTarget("strength", workout._id); - const allWorkouts = await listStrengthWorkouts(); + const analysis = await getLatestAnalysisForTarget(userId, "strength", workout._id); + const allWorkouts = await listStrengthWorkouts(userId); const pastWorkouts = allWorkouts.filter((w) => w.date <= workout.date); const exercisesWithHistory = workout.exercises.map((exercise) => ({ @@ -73,9 +75,9 @@ export default async function StrengthWorkoutPage({ {exercisesWithHistory.some(({ history }) => history.length >= 2) ? ( - Postęp ćwiczeń - - + Postęp ćwiczeń + + {exercisesWithHistory .filter(({ history }) => history.length >= 2) diff --git a/app/strength/import/actions.ts b/app/strength/import/actions.ts index 080e2ea..33494d2 100644 --- a/app/strength/import/actions.ts +++ b/app/strength/import/actions.ts @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { parseStrongShareText } from "@/lib/strong/parser"; import { upsertStrengthWorkout } from "@/lib/models/strength"; +import { getCurrentUserId } from "@/lib/session"; export type ImportStrongWorkoutState = { error: string } | null; @@ -27,8 +28,9 @@ export async function importStrongWorkout( return { error: "Nie znaleziono żadnego treningu w podanym tekście." }; } + const userId = await getCurrentUserId(); for (const workout of workouts) { - await upsertStrengthWorkout(workout); + await upsertStrengthWorkout(userId, workout); } revalidatePath("/strength"); diff --git a/app/strength/page.tsx b/app/strength/page.tsx index 3fbd8ec..036a3f4 100644 --- a/app/strength/page.tsx +++ b/app/strength/page.tsx @@ -5,13 +5,15 @@ import { VolumeChart } from "@/components/volume-chart"; import { formatDateShort } from "@/lib/format"; import { listStrengthWorkouts } from "@/lib/models/strength"; import { workoutVolumeKg } from "@/lib/strength/stats"; +import { getCurrentUserId } from "@/lib/session"; export const dynamic = "force-dynamic"; const VOLUME_CHART_LIMIT = 12; export default async function StrengthPage() { - const workouts = await listStrengthWorkouts(); + const userId = await getCurrentUserId(); + const workouts = await listStrengthWorkouts(userId); const volumeData = workouts .slice(0, VOLUME_CHART_LIMIT) diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..06da78f --- /dev/null +++ b/auth.ts @@ -0,0 +1,34 @@ +import NextAuth from "next-auth"; +import Keycloak from "next-auth/providers/keycloak"; + +export const { handlers, signIn, signOut, auth } = NextAuth({ + providers: [ + Keycloak({ + clientId: process.env.KEYCLOAK_CLIENT_ID!, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, + issuer: process.env.KEYCLOAK_ISSUER!, + }), + ], + callbacks: { + authorized({ auth }) { + return !!auth; + }, + jwt({ token, account }) { + if (account) { + // providerAccountId = Keycloak sub UUID, guaranteed on every login + token.keycloakId = account.providerAccountId; + token.accessToken = account.access_token; + token.idToken = account.id_token; + } + return token; + }, + session({ session, token }) { + session.user.id = (token.keycloakId ?? token.sub) as string; + session.idToken = token.idToken as string | undefined; + return session; + }, + }, + pages: { + signIn: "/login", + }, +}); diff --git a/components/elevation-chart.tsx b/components/elevation-chart.tsx index ec6c57b..42e24d3 100644 --- a/components/elevation-chart.tsx +++ b/components/elevation-chart.tsx @@ -1,13 +1,31 @@ "use client"; -import { useEffect, useState } from "react"; -import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { useEffect, useId, useState } from "react"; +import { + Area, + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +type Point = { distanceKm: number; altM: number; paceSec?: number }; type Props = { - data: { distanceKm: number; altM: number }[]; + data: Point[]; }; +function fmtPace(sec: number): string { + const m = Math.floor(sec / 60); + const s = Math.round(sec % 60); + return `${m}:${s.toString().padStart(2, "0")} /km`; +} + export function ElevationChart({ data }: Props) { + const uid = useId(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); @@ -18,15 +36,37 @@ export function ElevationChart({ data }: Props) { const altitudes = data.map((p) => p.altM); const minAlt = Math.min(...altitudes); const maxAlt = Math.max(...altitudes); - const pad = Math.max(5, Math.round((maxAlt - minAlt) * 0.15)); + const altPad = Math.max(5, Math.round((maxAlt - minAlt) * 0.15)); + + const pacePoints = data.map((p) => p.paceSec).filter((v): v is number => v != null && v > 0); + const hasPace = pacePoints.length > 5; + const minPace = hasPace ? Math.min(...pacePoints) : 0; + const maxPace = hasPace ? Math.max(...pacePoints) : 0; + const pacePad = Math.max(5, Math.round((maxPace - minPace) * 0.15)); + + const tooltipStyle = { + background: "var(--color-bg)", + border: "1px solid var(--color-muted)", + borderRadius: 8, + fontSize: 12, + color: "var(--color-fg)", + }; return ( - Profil wysokości + + Profil wysokości + {hasPace && ( + + + Tempo + + )} + - + - + @@ -41,33 +81,59 @@ export function ElevationChart({ data }: Props) { interval={Math.max(0, Math.floor(data.length / 5) - 1)} /> `${Math.round(v)} m`} - domain={[minAlt - pad, maxAlt + pad]} + domain={[minAlt - altPad, maxAlt + altPad]} /> + {hasPace && ( + + )} { + if (name === "altM") return [`${Math.round(Number(value))} m n.p.m.`, "Wysokość"]; + if (name === "paceSec") return [fmtPace(Number(value)), "Tempo"]; + return [value, name]; }} - formatter={(value) => [`${Math.round(Number(value))} m n.p.m.`, "Wysokość"]} - labelFormatter={(label) => `${Number(label).toFixed(2)} km`} + labelFormatter={(l) => `${Number(l).toFixed(2)} km`} /> - + {hasPace && ( + + )} + ); diff --git a/components/nav.tsx b/components/nav.tsx index a447f96..254832f 100644 --- a/components/nav.tsx +++ b/components/nav.tsx @@ -1,5 +1,7 @@ import Link from "next/link"; import { Activity, Dumbbell, LayoutDashboard, Settings } from "lucide-react"; +import { auth, signOut } from "@/auth"; +import { SignOutButton } from "./sign-out-button"; const links = [ { href: "/", label: "Panel", icon: LayoutDashboard }, @@ -8,7 +10,14 @@ const links = [ { href: "/settings", label: "Ustawienia", icon: Settings }, ]; -export function Nav() { +export async function Nav() { + const session = await auth(); + + const signOutAction = async () => { + "use server"; + await signOut({ redirectTo: "/login" }); + }; + return ( @@ -39,6 +48,12 @@ export function Nav() { {label} ))} + {session?.user?.name && ( + + {session.user.name} + + )} + diff --git a/components/run-metric-chart.tsx b/components/run-metric-chart.tsx index e0c1b00..220ff87 100644 --- a/components/run-metric-chart.tsx +++ b/components/run-metric-chart.tsx @@ -20,6 +20,8 @@ type Props = { color?: string; referenceLine?: number; decimals?: number; + format?: "pace"; + reversed?: boolean; }; export function RunMetricChart({ @@ -29,6 +31,8 @@ export function RunMetricChart({ color = "var(--color-accent)", referenceLine, decimals = 0, + format, + reversed = false, }: Props) { const uid = useId(); const gradId = `grad-${uid.replace(/:/g, "")}`; @@ -43,8 +47,14 @@ export function RunMetricChart({ const min = Math.min(...values); const max = Math.max(...values); const pad = Math.max(1, Math.round((max - min) * 0.15)); - const fmt = (v: number) => - decimals > 0 ? `${Number(v).toFixed(decimals)} ${unit}` : `${Math.round(v)} ${unit}`; + const fmt = (v: number) => { + if (format === "pace") { + const m = Math.floor(v / 60); + const s = Math.round(v % 60); + return `${m}:${s.toString().padStart(2, "0")} /km`; + } + return decimals > 0 ? `${Number(v).toFixed(decimals)} ${unit}` : `${Math.round(v)} ${unit}`; + }; return ( @@ -72,7 +82,8 @@ export function RunMetricChart({ fontSize={11} width={50} tickFormatter={fmt} - domain={[min - pad, max + pad]} + domain={reversed ? [max + pad, min - pad] : [min - pad, max + pad]} + reversed={reversed} /> Promise }) { + return ( + + + + Wyloguj + + + ); +} diff --git a/lib/ai/claude.ts b/lib/ai/claude.ts index e183789..8f4a8a1 100644 --- a/lib/ai/claude.ts +++ b/lib/ai/claude.ts @@ -28,9 +28,15 @@ Jeśli podano dane z poprzednich treningów, odnieś się do progresu (np. zmian type PreviousRun = { run: RunningActivity; analysis: AiAnalysis | null }; type PreviousWorkout = { workout: StrengthWorkout; analysis: AiAnalysis | null }; +function secToMinKm(sec: number): string { + const m = Math.floor(sec / 60); + const s = Math.round(sec % 60); + return `${m}:${s.toString().padStart(2, "0")} min/km`; +} + function buildRunMetricsSummary(metrics: RunMetrics, totalDistanceM: number): string[] { - const { distanceKm, hrBpm, gcbLeftPct } = metrics; - if (!hrBpm && !gcbLeftPct) return []; + const { distanceKm, hrBpm, gcbLeftPct, paceSec } = metrics; + if (!hrBpm && !gcbLeftPct && !paceSec) return []; const n = distanceKm.length; if (n < 8) return []; @@ -60,6 +66,11 @@ function buildRunMetricsSummary(metrics: RunMetrics, totalDistanceM: number): st const a = avg(vals); if (a !== null) parts.push(`HR śr. ${a} bpm`); } + if (paceSec) { + const vals = idx.map((i) => paceSec[i]).filter((v) => v > 0 && v < 1800); + const a = avg(vals); + if (a !== null) parts.push(`tempo śr. ${secToMinKm(a)}`); + } if (gcbLeftPct) { const vals = idx.map((i) => gcbLeftPct[i]).filter((v) => v > 0); if (vals.length > 0) { @@ -184,6 +195,7 @@ function parseAnalysisResponse(text: string): { summary: string; tips: string[] } export async function generateAnalysis( + userId: string, targetType: AiAnalysisTargetType, targetId: string ): Promise { @@ -194,28 +206,28 @@ export async function generateAnalysis( let prompt: string; if (targetType === "running") { - const activity = await getRunningActivity(targetId); + const activity = await getRunningActivity(userId, targetId); if (!activity) throw new Error("Nie znaleziono biegu."); - const previousRuns = (await listRunningActivities()) + const previousRuns = (await listRunningActivities(userId)) .filter((run) => run.startTime < activity.startTime) .slice(0, PREVIOUS_RUNS_LIMIT); const previousRunsWithAnalysis: PreviousRun[] = await Promise.all( previousRuns.map(async (run) => ({ run, - analysis: await getLatestAnalysisForTarget("running", run._id), + analysis: await getLatestAnalysisForTarget(userId, "running", run._id), })) ); prompt = buildRunningPrompt(activity, previousRunsWithAnalysis); } else { - const workout = await getStrengthWorkout(targetId); + const workout = await getStrengthWorkout(userId, targetId); if (!workout) throw new Error("Nie znaleziono treningu."); - const previousWorkouts = (await listStrengthWorkouts()) + const previousWorkouts = (await listStrengthWorkouts(userId)) .filter((previous) => previous.date < workout.date) .slice(0, PREVIOUS_WORKOUTS_LIMIT); const previousWorkoutsWithAnalysis: PreviousWorkout[] = await Promise.all( previousWorkouts.map(async (previous) => ({ workout: previous, - analysis: await getLatestAnalysisForTarget("strength", previous._id), + analysis: await getLatestAnalysisForTarget(userId, "strength", previous._id), })) ); prompt = buildStrengthPrompt(workout, previousWorkoutsWithAnalysis); @@ -234,6 +246,7 @@ export async function generateAnalysis( const { summary, tips } = parseAnalysisResponse(text); return saveAiAnalysis({ + userId, targetType, targetId: new ObjectId(targetId), summary, @@ -332,18 +345,18 @@ function buildDashboardPrompt( return lines.join("\n"); } -export async function generateDashboardAnalysis(): Promise { +export async function generateDashboardAnalysis(userId: string): Promise { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) throw new Error("Brak klucza ANTHROPIC_API_KEY w konfiguracji."); const [runs, workouts] = await Promise.all([ - listRunningActivities().then((r) => r.slice(0, DASHBOARD_RUNS_LIMIT)), - listStrengthWorkouts().then((w) => w.slice(0, DASHBOARD_WORKOUTS_LIMIT)), + listRunningActivities(userId).then((r) => r.slice(0, DASHBOARD_RUNS_LIMIT)), + listStrengthWorkouts(userId).then((w) => w.slice(0, DASHBOARD_WORKOUTS_LIMIT)), ]); let wellness: DayWellness[] = []; try { - const garminClient = await getAuthorizedClient(); + const garminClient = await getAuthorizedClient(userId); wellness = await fetchRecentWellness(garminClient, DASHBOARD_WELLNESS_DAYS); } catch { // Wellness data not available, proceed without it @@ -361,5 +374,5 @@ export async function generateDashboardAnalysis(): Promise { const textBlock = message.content.find((b) => b.type === "text"); const text = textBlock && textBlock.type === "text" ? textBlock.text : ""; const { summary, tips } = parseAnalysisResponse(text); - return saveDashboardAnalysis(summary, tips, model); + return saveDashboardAnalysis(userId, summary, tips, model); } diff --git a/lib/garmin/client.ts b/lib/garmin/client.ts index c44462f..4249cbb 100644 --- a/lib/garmin/client.ts +++ b/lib/garmin/client.ts @@ -2,7 +2,7 @@ import { GarminConnect } from "garmin-connect"; import type { IActivity } from "garmin-connect/dist/garmin/types/activity"; import type { IOauth1Token } from "garmin-connect/dist/garmin/types"; import type { RoutePoint, RunMetrics, RunningActivityInput } from "@/lib/models/running"; -import { getSavedOauth1Token } from "@/lib/models/garmin-auth"; +import { getGarminCredentials, getSavedOauth1Token } from "@/lib/models/garmin-auth"; import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso"; const FETCH_LIMIT = 50; @@ -13,6 +13,12 @@ export class GarminLoginRequiredError extends Error { } } +export class GarminCredentialsMissingError extends Error { + constructor() { + super("Brak danych logowania do Garmin Connect. Skonfiguruj je w Ustawieniach."); + } +} + function parseGarminDate(value: string): Date { return new Date(`${value.replace(" ", "T")}Z`); } @@ -154,7 +160,6 @@ export async function fetchActivityRunMetrics( const step = Math.max(1, Math.floor(rows.length / MAX)); const sampled = rows.filter((_, i) => i % step === 0 || i === rows.length - 1); - // Try several known Garmin distance metric keys const distKey = ["directDistance", "sumDistance", "directCumulativeDistance"].find( (k) => sampled.some((row) => get(row, k) !== null && get(row, k)! > 0) ); @@ -172,24 +177,21 @@ export async function fetchActivityRunMetrics( return values.some((v) => v > 0) ? values : undefined; }; + const speedSeries = series("directSpeed", 3); + const paceSec = speedSeries + ? speedSeries.map((v) => (v > 0.5 ? Math.round(1000 / v) : 0)) + : undefined; + return { distanceKm, hrBpm: series("directHeartRate"), cadenceSpm: series("directDoubleCadence"), gctMs: series("directGroundContactTime"), gcbLeftPct: series("directGroundContactBalanceLeft", 1), + paceSec, }; } -function getCredentials(): { username: string; password: string } { - const username = process.env.GARMIN_EMAIL; - const password = process.env.GARMIN_PASSWORD; - if (!username || !password) { - throw new Error("Brak danych logowania do Garmin Connect (GARMIN_EMAIL / GARMIN_PASSWORD)."); - } - return { username, password }; -} - async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1Token): Promise { const http = client.client; if (!http.OAUTH_CONSUMER) { @@ -205,12 +207,8 @@ async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1To await http.exchange({ oauth, token: oauth1Token }); } -/** - * Returns a client authenticated using a previously saved OAuth1 token - * (long-lived, survives across syncs) - no MFA needed if it's still valid. - */ -export async function getAuthorizedClient(): Promise { - const saved = await getSavedOauth1Token(); +export async function getAuthorizedClient(userId: string): Promise { + const saved = await getSavedOauth1Token(userId); if (!saved) { throw new GarminLoginRequiredError(); } @@ -232,16 +230,16 @@ async function establishClientFromTicket(ticket: string): Promise<{ client: Garm return { client, oauth1Token: oauth1.token }; } -/** - * Starts a fresh SSO login using env credentials. If the account requires - * MFA, returns the pending state needed to complete it via - * `completeGarminMfaLogin` once the user supplies the emailed code. - */ -export async function beginGarminLogin(): Promise< - { client: GarminConnect; oauth1Token: IOauth1Token } | { mfaRequired: true; pendingState: GarminPendingMfa } +export async function beginGarminLogin( + userId: string +): Promise< + | { client: GarminConnect; oauth1Token: IOauth1Token } + | { mfaRequired: true; pendingState: GarminPendingMfa } > { - const { username, password } = getCredentials(); - const result = await loginAndGetTicket(username, password); + const creds = await getGarminCredentials(userId); + if (!creds) throw new GarminCredentialsMissingError(); + + const result = await loginAndGetTicket(creds.email, creds.password); if ("mfaRequired" in result) return result; return establishClientFromTicket(result.ticket); } @@ -254,14 +252,7 @@ export async function completeGarminMfaLogin( return establishClientFromTicket(ticket); } -/** - * Returns all recent running activities (mapped), regardless of `since` - - * callers should upsert all of them so previously-synced activities get - * backfilled with newly added metric fields, but can use `since` to decide - * which ones are "new" for reporting purposes. - */ export async function fetchRunningActivities(client: GarminConnect): Promise { const activities = await client.getActivities(0, FETCH_LIMIT); - return activities.filter(isRunningActivity).map(mapActivity); } diff --git a/lib/models/analysis.ts b/lib/models/analysis.ts index 6b1eace..6471f01 100644 --- a/lib/models/analysis.ts +++ b/lib/models/analysis.ts @@ -4,6 +4,7 @@ import { getDb } from "@/lib/db"; export type AiAnalysisTargetType = "running" | "strength" | "dashboard"; export type AiAnalysisInput = { + userId: string; targetType: AiAnalysisTargetType; targetId: ObjectId; summary: string; @@ -40,6 +41,8 @@ export function serializeAnalysis(analysis: AiAnalysis): SerializedAiAnalysis { const COLLECTION = "ai_analyses"; +const DASHBOARD_TARGET_ID = new ObjectId("000000000000000000000001"); + async function getCollection() { const db = await getDb(); return db.collection(COLLECTION); @@ -53,32 +56,34 @@ export async function saveAiAnalysis(input: AiAnalysisInput): Promise { const collection = await getCollection(); - return collection.findOne({ targetType, targetId }, { sort: { createdAt: -1 } }); + return collection.findOne({ userId, targetType, targetId }, { sort: { createdAt: -1 } }); } -export async function getLatestAnalysis(): Promise { - const collection = await getCollection(); - return collection.findOne({}, { sort: { createdAt: -1 } }); -} - -const DASHBOARD_TARGET_ID = new ObjectId("000000000000000000000001"); - -export async function getDashboardAnalysis(): Promise { +export async function getDashboardAnalysis(userId: string): Promise { const collection = await getCollection(); return collection.findOne( - { targetType: "dashboard", targetId: DASHBOARD_TARGET_ID }, + { userId, targetType: "dashboard", targetId: DASHBOARD_TARGET_ID }, { sort: { createdAt: -1 } } ); } export async function saveDashboardAnalysis( + userId: string, summary: string, tips: string[], model: string ): Promise { - return saveAiAnalysis({ targetType: "dashboard", targetId: DASHBOARD_TARGET_ID, summary, tips, model }); + return saveAiAnalysis({ + userId, + targetType: "dashboard", + targetId: DASHBOARD_TARGET_ID, + summary, + tips, + model, + }); } diff --git a/lib/models/garmin-auth.ts b/lib/models/garmin-auth.ts index 67f3954..31edaac 100644 --- a/lib/models/garmin-auth.ts +++ b/lib/models/garmin-auth.ts @@ -4,37 +4,61 @@ import type { GarminPendingMfa } from "@/lib/garmin/sso"; const AUTH_COLLECTION = "garmin_auth"; const PENDING_COLLECTION = "garmin_login_pending"; +const CREDENTIALS_COLLECTION = "garmin_credentials"; -type GarminAuthDoc = { _id: "tokens"; oauth1Token: IOauth1Token; updatedAt: Date }; -type GarminPendingDoc = { _id: "pending"; state: GarminPendingMfa; createdAt: Date }; +type GarminAuthDoc = { _id: string; oauth1Token: IOauth1Token; updatedAt: Date }; +type GarminPendingDoc = { _id: string; state: GarminPendingMfa; createdAt: Date }; +type GarminCredentialsDoc = { _id: string; email: string; password: string }; -export async function getSavedOauth1Token(): Promise { +export async function getSavedOauth1Token(userId: string): Promise { const db = await getDb(); - const doc = await db.collection(AUTH_COLLECTION).findOne({ _id: "tokens" }); + const doc = await db.collection(AUTH_COLLECTION).findOne({ _id: userId }); return doc?.oauth1Token ?? null; } -export async function saveOauth1Token(oauth1Token: IOauth1Token): Promise { +export async function saveOauth1Token(userId: string, oauth1Token: IOauth1Token): Promise { const db = await getDb(); await db .collection(AUTH_COLLECTION) - .updateOne({ _id: "tokens" }, { $set: { oauth1Token, updatedAt: new Date() } }, { upsert: true }); + .updateOne({ _id: userId }, { $set: { oauth1Token, updatedAt: new Date() } }, { upsert: true }); } -export async function savePendingMfaState(state: GarminPendingMfa): Promise { +export async function savePendingMfaState(userId: string, state: GarminPendingMfa): Promise { const db = await getDb(); await db .collection(PENDING_COLLECTION) - .updateOne({ _id: "pending" }, { $set: { state, createdAt: new Date() } }, { upsert: true }); + .updateOne({ _id: userId }, { $set: { state, createdAt: new Date() } }, { upsert: true }); } -export async function getPendingMfaState(): Promise { +export async function getPendingMfaState(userId: string): Promise { const db = await getDb(); - const doc = await db.collection(PENDING_COLLECTION).findOne({ _id: "pending" }); + const doc = await db.collection(PENDING_COLLECTION).findOne({ _id: userId }); return doc?.state ?? null; } -export async function clearPendingMfaState(): Promise { +export async function clearPendingMfaState(userId: string): Promise { const db = await getDb(); - await db.collection(PENDING_COLLECTION).deleteOne({ _id: "pending" }); + await db.collection(PENDING_COLLECTION).deleteOne({ _id: userId }); +} + +export async function getGarminCredentials( + userId: string +): Promise<{ email: string; password: string } | null> { + const db = await getDb(); + const doc = await db + .collection(CREDENTIALS_COLLECTION) + .findOne({ _id: userId }); + if (!doc) return null; + return { email: doc.email, password: doc.password }; +} + +export async function saveGarminCredentials( + userId: string, + email: string, + password: string +): Promise { + const db = await getDb(); + await db + .collection(CREDENTIALS_COLLECTION) + .updateOne({ _id: userId }, { $set: { email, password } }, { upsert: true }); } diff --git a/lib/models/running.ts b/lib/models/running.ts index 2a33042..f45a19a 100644 --- a/lib/models/running.ts +++ b/lib/models/running.ts @@ -40,10 +40,12 @@ export type RunMetrics = { cadenceSpm?: number[]; gctMs?: number[]; gcbLeftPct?: number[]; + paceSec?: number[]; }; export type RunningActivity = RunningActivityInput & { _id: ObjectId; + userId: string; createdAt: Date; routePoints?: RoutePoint[]; elevationProfile?: number[]; @@ -56,60 +58,73 @@ const SYNC_STATE_COLLECTION = "sync_state"; async function getCollection() { const db = await getDb(); const collection = db.collection(COLLECTION); - await collection.createIndex({ garminActivityId: 1 }, { unique: true }); + await collection.createIndex({ userId: 1, garminActivityId: 1 }, { unique: true }); return collection; } -export async function upsertRunningActivity(activity: RunningActivityInput): Promise { +export async function upsertRunningActivity( + userId: string, + activity: RunningActivityInput +): Promise { const collection = await getCollection(); await collection.updateOne( - { garminActivityId: activity.garminActivityId }, + { userId, garminActivityId: activity.garminActivityId }, { - $set: activity, + $set: { ...activity, userId }, $setOnInsert: { createdAt: new Date() }, }, { upsert: true } ); } -export async function listRunningActivities(): Promise { +export async function listRunningActivities(userId: string): Promise { const collection = await getCollection(); - return collection.find().sort({ startTime: -1 }).toArray(); + return collection.find({ userId }).sort({ startTime: -1 }).toArray(); } -export async function getRunningActivity(id: string): Promise { +export async function getRunningActivity( + userId: string, + id: string +): Promise { const collection = await getCollection(); - return collection.findOne({ _id: new ObjectId(id) }); + return collection.findOne({ _id: new ObjectId(id), userId }); } export async function setRunningActivityMetrics( + userId: string, garminActivityId: number, metrics: RunMetrics ): Promise { const collection = await getCollection(); - await collection.updateOne({ garminActivityId }, { $set: { runMetrics: metrics } }); + await collection.updateOne({ userId, garminActivityId }, { $set: { runMetrics: metrics } }); } export async function setRunningActivityRoutePoints( + userId: string, garminActivityId: number, points: RoutePoint[], elevationProfile: number[] ): Promise { const collection = await getCollection(); - await collection.updateOne({ garminActivityId }, { $set: { routePoints: points, elevationProfile } }); + await collection.updateOne( + { userId, garminActivityId }, + { $set: { routePoints: points, elevationProfile } } + ); } -type SyncState = { _id: "garmin"; lastSyncAt: Date }; +type SyncState = { _id: string; lastSyncAt: Date }; -export async function getLastSyncAt(): Promise { +export async function getLastSyncAt(userId: string): Promise { const db = await getDb(); - const state = await db.collection(SYNC_STATE_COLLECTION).findOne({ _id: "garmin" }); + const state = await db + .collection(SYNC_STATE_COLLECTION) + .findOne({ _id: userId }); return state?.lastSyncAt ?? null; } -export async function setLastSyncAt(date: Date): Promise { +export async function setLastSyncAt(userId: string, date: Date): Promise { const db = await getDb(); await db .collection(SYNC_STATE_COLLECTION) - .updateOne({ _id: "garmin" }, { $set: { lastSyncAt: date } }, { upsert: true }); + .updateOne({ _id: userId }, { $set: { lastSyncAt: date } }, { upsert: true }); } diff --git a/lib/models/strength.ts b/lib/models/strength.ts index 409744f..bce1a5d 100644 --- a/lib/models/strength.ts +++ b/lib/models/strength.ts @@ -29,6 +29,7 @@ export type StrengthWorkoutInput = z.infer; export type StrengthWorkout = StrengthWorkoutInput & { _id: ObjectId; + userId: string; createdAt: Date; }; @@ -37,30 +38,34 @@ const COLLECTION = "strength_workouts"; async function getCollection() { const db = await getDb(); const collection = db.collection(COLLECTION); - await collection.createIndex({ sourceKey: 1 }, { unique: true }); + await collection.createIndex({ userId: 1, sourceKey: 1 }, { unique: true }); return collection; } export async function upsertStrengthWorkout( + userId: string, workout: StrengthWorkoutInput ): Promise { const collection = await getCollection(); await collection.updateOne( - { sourceKey: workout.sourceKey }, + { userId, sourceKey: workout.sourceKey }, { - $set: workout, + $set: { ...workout, userId }, $setOnInsert: { createdAt: new Date() }, }, { upsert: true } ); } -export async function listStrengthWorkouts(): Promise { +export async function listStrengthWorkouts(userId: string): Promise { const collection = await getCollection(); - return collection.find().sort({ date: -1 }).toArray(); + return collection.find({ userId }).sort({ date: -1 }).toArray(); } -export async function getStrengthWorkout(id: string): Promise { +export async function getStrengthWorkout( + userId: string, + id: string +): Promise { const collection = await getCollection(); - return collection.findOne({ _id: new ObjectId(id) }); + return collection.findOne({ _id: new ObjectId(id), userId }); } diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..c4d51ac --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,8 @@ +import { auth } from "@/auth"; + +export async function getCurrentUserId(): Promise { + const session = await auth(); + const id = session?.user?.id; + if (!id) throw new Error("Użytkownik nie jest zalogowany."); + return id; +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..bbed6e0 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,23 @@ +import { auth } from "@/auth"; +import { NextResponse } from "next/server"; + +export default auth((req) => { + const isAuthenticated = !!req.auth; + const { pathname } = req.nextUrl; + + if (!isAuthenticated && pathname !== "/login") { + return NextResponse.redirect(new URL("/login", req.url)); + } + + if (isAuthenticated && pathname === "/login") { + return NextResponse.redirect(new URL("/", req.url)); + } + + return NextResponse.next(); +}); + +export const config = { + matcher: [ + "/((?!api/auth|_next/static|_next/image|favicon.ico|icon.svg|logo.svg|public/).*)", + ], +}; diff --git a/package.json b/package.json index 8e563b9..f08aa18 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lucide-react": "^1.18.0", "mongodb": "^7.3.0", "next": "16.2.9", + "next-auth": "5.0.0-beta.31", "react": "19.2.4", "react-dom": "19.2.4", "react-leaflet": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b8f32a..0715565 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: next: specifier: 16.2.9 version: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-auth: + specifier: 5.0.0-beta.31 + version: 5.0.0-beta.31(next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -91,6 +94,20 @@ packages: zod: optional: true + '@auth/core@0.41.2': + resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^7.0.7 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -464,6 +481,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@react-leaflet/core@3.0.0': resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==} peerDependencies: @@ -1598,6 +1618,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1832,6 +1855,22 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-auth@5.0.0-beta.31: + resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 + react: ^18.2.0 || ^19.0.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + next@16.2.9: resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} engines: {node: '>=20.9.0'} @@ -1864,6 +1903,9 @@ packages: oauth-1.0a@2.2.6: resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==} + oauth4webapi@3.8.6: + resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1950,6 +1992,14 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2338,6 +2388,14 @@ snapshots: optionalDependencies: zod: 4.4.3 + '@auth/core@0.41.2': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.3 + oauth4webapi: 3.8.6 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2694,6 +2752,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@panva/hkdf@1.2.1': {} + '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: leaflet: 1.9.4 @@ -3969,6 +4029,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} js-yaml@4.2.0: @@ -4139,6 +4201,12 @@ snapshots: natural-compare@1.4.0: {} + next-auth@5.0.0-beta.31(next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + dependencies: + '@auth/core': 0.41.2 + next: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.9 @@ -4174,6 +4242,8 @@ snapshots: oauth-1.0a@2.2.6: {} + oauth4webapi@3.8.6: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -4269,6 +4339,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + + preact@10.24.3: {} + prelude-ls@1.2.1: {} prop-types@15.8.1: diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts new file mode 100644 index 0000000..04401ae --- /dev/null +++ b/types/next-auth.d.ts @@ -0,0 +1,16 @@ +import "next-auth"; +import "next-auth/jwt"; + +declare module "next-auth" { + interface Session { + idToken?: string; + } +} + +declare module "next-auth/jwt" { + interface JWT { + keycloakId?: string; + idToken?: string; + accessToken?: string; + } +}
+ Książka Notowań Udźwigów i Rezultatów +
{state.error}
Zapisano dane logowania.
Status konfiguracji i synchronizacja Garmin.
Konfiguracja konta i synchronizacja Garmin.