feat: add TypeScript definitions for next-auth session with accessToken and user details
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-04 20:09:21 +02:00
parent afd2607000
commit ffe50e5151
135 changed files with 5 additions and 38 deletions
+39
View File
@@ -0,0 +1,39 @@
import { redirect } from 'next/navigation';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
// Använd alltid relativ path i webbläsaren för att undvika mixed content
const isServer = typeof window === 'undefined';
const url = isServer
? (path.startsWith('http') ? path : `${API_BASE}${path}`)
: path;
// Dynamisk import så att auth-headers inte bundlas till klienten
const authHeaders = isServer
? await (await import('./auth-headers')).getAuthHeaders()
: {};
const res = await fetch(url, {
...init,
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
...authHeaders,
...(init?.headers || {}),
},
});
if (!res.ok) {
if (res.status === 401 && isServer) {
redirect('/login');
}
const text = await res.text();
throw new Error(`API ${res.status}: ${text}`);
}
return res.json();
}
export { API_BASE };
+24
View File
@@ -0,0 +1,24 @@
import { auth } from '../auth';
/**
* Returnerar Authorization-header med JWT från sessionen.
* Används i alla server-side API-proxy-routes.
*/
export async function getAuthHeaders(): Promise<Record<string, string>> {
const session = await auth();
// eslint-disable-next-line no-console
console.log('[getAuthHeaders] Session data:', {
hasSession: !!session,
hasAccessToken: !!session?.accessToken,
sessionKeys: session ? Object.keys(session) : [],
userRole: (session?.user as any)?.role,
});
if (!session?.accessToken) {
// eslint-disable-next-line no-console
console.warn('[getAuthHeaders] No accessToken found! Session:', session);
return {};
}
// eslint-disable-next-line no-console
console.log('[getAuthHeaders] Returning Bearer token');
return { Authorization: `Bearer ${session.accessToken}` };
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Utility för att parse HTTP-responses och extrahera tydliga felmeddelanden
*/
// Deklarativ mappning av kända tekniska felsträngar → svenska meddelanden
const MESSAGE_MAP: Array<{ match: string; label: string }> = [
{ match: 'User_email_key', label: 'E-postadressen används redan av en annan användare.' },
{ match: 'Det finns redan en annan produkt med detta namn', label: 'Det finns redan en annan produkt med detta namn. Välj ett unikt namn.' },
];
function translateMessage(msg: string): string {
const found = MESSAGE_MAP.find((entry) => msg.includes(entry.match));
return found ? found.label : msg;
}
export async function parseErrorResponse(response: Response): Promise<string> {
const status = response.status;
// Läs body som text en gång — Response.body kan bara konsumeras en gång
let bodyText = '';
try {
bodyText = await response.text();
} catch {
// Body kunde inte läsas
}
// Försök tolka som JSON
try {
const data = JSON.parse(bodyText);
// NestJS class-validator kan returnera message som array
if (Array.isArray(data.message) && data.message.length > 0) {
return translateMessage(String(data.message[0]));
}
if (typeof data.message === 'string') {
return translateMessage(data.message);
}
if (typeof data.error === 'string') {
return translateMessage(data.error);
}
if (typeof data.details === 'string') {
return translateMessage(data.details);
}
} catch {
// Inte JSON — använd råtexten om den är kortfattad
if (bodyText && bodyText.length < 200) {
return bodyText;
}
}
// Fallback baserat på HTTP-status
const defaultMessages: Record<number, string> = {
400: 'Ogiltiga data. Kontrollera dina inmatningar.',
401: 'Du är inte autentiserad. Logga in.',
403: 'Du har inte behörighet till detta.',
404: 'Resursen hittades inte.',
409: 'Konflikt med befintlig data.',
422: 'Valideringen misslyckades. Kontrollera dina inmatningar.',
500: 'Serverfel. Försök igen senare.',
503: 'Tjänsten är inte tillgänglig.',
};
return defaultMessages[status] || `Fel (${status}). Försök igen senare.`;
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Central enhetsdatabas för Recipe App (frontend-spegel av backend/src/common/utils/units.ts).
* Håll dessa filer i synk vid ändringar.
*/
export type UnitType = 'weight' | 'volume' | 'cooking' | 'piece' | 'other';
export interface UnitDefinition {
value: string;
labelSv: string;
type: UnitType;
toBaseFactor: number;
aliases: string[];
}
export const UNIT_DEFINITIONS: UnitDefinition[] = [
// ── Vikt ──────────────────────────────────────────────────
{ value: 'g', labelSv: 'g (gram)', type: 'weight', toBaseFactor: 1, aliases: ['gram'] },
{ value: 'hg', labelSv: 'hg (hektogram)', type: 'weight', toBaseFactor: 100, aliases: ['hektogram'] },
{ value: 'kg', labelSv: 'kg (kilogram)', type: 'weight', toBaseFactor: 1000, aliases: ['kilo', 'kilogram'] },
{ value: 'mg', labelSv: 'mg (milligram)', type: 'weight', toBaseFactor: 0.001, aliases: ['milligram'] },
// ── Volym ─────────────────────────────────────────────────
{ value: 'ml', labelSv: 'ml (milliliter)', type: 'volume', toBaseFactor: 1, aliases: ['milliliter'] },
{ value: 'cl', labelSv: 'cl (centiliter)', type: 'volume', toBaseFactor: 10, aliases: ['centiliter'] },
{ value: 'dl', labelSv: 'dl (deciliter)', type: 'volume', toBaseFactor: 100, aliases: ['deciliter'] },
{ value: 'l', labelSv: 'l (liter)', type: 'volume', toBaseFactor: 1000, aliases: ['liter'] },
// ── Matlagning ────────────────────────────────────────────
{ value: 'krm', labelSv: 'krm (kryddmått)', type: 'cooking', toBaseFactor: 1, aliases: ['kryddmatt', 'kryddmått'] },
{ value: 'tsk', labelSv: 'tsk (tesked)', type: 'cooking', toBaseFactor: 5, aliases: ['tesked', 'test'] },
{ value: 'msk', labelSv: 'msk (matsked)', type: 'cooking', toBaseFactor: 15, aliases: ['matsked', 'matsled'] },
// ── Styck ─────────────────────────────────────────────────
{ value: 'st', labelSv: 'st (styck)', type: 'piece', toBaseFactor: 1, aliases: ['stycke', 'styck', 'stk'] },
{ value: 'port', labelSv: 'port (portioner)', type: 'piece', toBaseFactor: 1, aliases: ['portion', 'portioner'] },
{ value: 'förp', labelSv: 'förp (förpackning)', type: 'piece', toBaseFactor: 1, aliases: ['forp', 'förpackning', 'forpackning'] },
{ value: 'klyfta', labelSv: 'klyfta', type: 'piece', toBaseFactor: 1, aliases: [] },
// ── Övrigt ────────────────────────────────────────────────
{ value: 'efter smak', labelSv: 'efter smak', type: 'other', toBaseFactor: 1, aliases: ['eftr smak'] },
];
/** Alla enheter som { value, label } — bakåtkompatibel ersättning för gamla UNIT_OPTIONS */
export const UNIT_OPTIONS = [
{ value: '', label: 'Välj enhet' },
...UNIT_DEFINITIONS.map((u) => ({ value: u.value, label: u.labelSv })),
];
/** Enheter grupperade per typ — för användning i dropdown med optgroup */
export const UNIT_OPTIONS_GROUPED: { group: string; options: { value: string; label: string }[] }[] = [
{
group: 'Vikt',
options: UNIT_DEFINITIONS.filter((u) => u.type === 'weight').map((u) => ({ value: u.value, label: u.labelSv })),
},
{
group: 'Volym',
options: UNIT_DEFINITIONS.filter((u) => u.type === 'volume').map((u) => ({ value: u.value, label: u.labelSv })),
},
{
group: 'Matlagning',
options: UNIT_DEFINITIONS.filter((u) => u.type === 'cooking').map((u) => ({ value: u.value, label: u.labelSv })),
},
{
group: 'Styck',
options: UNIT_DEFINITIONS.filter((u) => u.type === 'piece').map((u) => ({ value: u.value, label: u.labelSv })),
},
{
group: 'Övrigt',
options: UNIT_DEFINITIONS.filter((u) => u.type === 'other').map((u) => ({ value: u.value, label: u.labelSv })),
},
];
/** Normalisera en enhetssträng till kanonisk förkortning. */
export function normalizeUnit(unit: string): string {
const key = unit.trim().toLowerCase();
for (const def of UNIT_DEFINITIONS) {
if (def.value.toLowerCase() === key) return def.value;
if (def.aliases.some((a) => a.toLowerCase() === key)) return def.value;
}
return key;
}
/** Hämta UnitDefinition för en enhet. */
export function getUnitDefinition(unit: string): UnitDefinition | undefined {
const key = unit.trim().toLowerCase();
return UNIT_DEFINITIONS.find(
(d) => d.value.toLowerCase() === key || d.aliases.some((a) => a.toLowerCase() === key),
);
}
/** Hämta enhetstypen för en enhet. */
export function getUnitType(unit: string): UnitType | null {
return getUnitDefinition(unit)?.type ?? null;
}
/** Kontrollera om konvertering är möjlig mellan två enheter. */
export function canConvert(fromUnit: string, toUnit: string): boolean {
const from = getUnitDefinition(fromUnit);
const to = getUnitDefinition(toUnit);
if (!from || !to) return false;
if (from.type !== to.type) return false;
if (from.type === 'piece' || from.type === 'other') return false;
return true;
}
/** Konverterar en mängd från en enhet till en annan. */
export function convertUnit(quantity: number, fromUnit: string, toUnit: string): number {
const fromDef = getUnitDefinition(fromUnit);
const toDef = getUnitDefinition(toUnit);
if (!fromDef || !toDef || fromDef.type !== toDef.type) return quantity;
if (fromDef.type === 'piece' || fromDef.type === 'other') return quantity;
return (quantity * fromDef.toBaseFactor) / toDef.toBaseFactor;
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { useSession } from 'next-auth/react';
import { useCallback } from 'react';
/**
* Hook som returnerar en fetch-funktion med Authorization-header automatiskt ifylld.
* Används i klientkomponenter som gör anrop till endpoints som Caddy routar direkt
* till NestJS (t.ex. /api/recipes*, /api/products*, /api/inventory*).
*
* Exempel:
* const authFetch = useAuthFetch();
* const res = await authFetch('/api/recipes/1', { method: 'PATCH', body: JSON.stringify(data) });
*/
export function useAuthFetch() {
const sessionResult = useSession();
const accessToken = sessionResult?.data?.accessToken ?? '';
return useCallback(
(url: string, init: RequestInit = {}): Promise<Response> => {
const headers = new Headers(init.headers);
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
if (!headers.has('Content-Type') && init.body && typeof init.body === 'string') {
headers.set('Content-Type', 'application/json');
}
return fetch(url, { ...init, headers });
},
[accessToken],
);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Hjälpfunktion för att wrappa Next.js Route Handlers med NextAuth auth().
* Löser problemet med att auth() standalone inte fungerar i route handlers
* med Next.js 15+/16 (async cookies-kompatibilitet i NextAuth beta).
*
* request.auth = session-objektet (inkl. accessToken)
*/
import { NextResponse } from 'next/server';
import { auth } from '../auth';
export type AuthedRequest = Request & { auth: { accessToken?: string; user?: any } | null };
/**
* Returnerar Authorization-headern från en autentiserad request.
* Kastar 401-svar om sessionen saknar accessToken.
*/
export function getBearer(session: AuthedRequest['auth']): string | null {
if (!session?.accessToken) return null;
return `Bearer ${session.accessToken}`;
}
/**
* Wrapper: export const GET = withAuth(async (req, session, context) => { ... })
*/
export function withAuth(
handler: (req: Request, session: NonNullable<AuthedRequest['auth']>, context: any) => Promise<Response>,
) {
return auth(async function (request: any, context: any) {
let session = request.auth;
// eslint-disable-next-line no-console
console.log('[withAuth] request.auth:', JSON.stringify(session));
// Fallback: om wrapper-formen inte populerar request.auth, försök standalone
if (!session?.accessToken) {
// eslint-disable-next-line no-console
console.log('[withAuth] Trying auth() standalone fallback...');
session = await auth();
// eslint-disable-next-line no-console
console.log('[withAuth] standalone session:', JSON.stringify(session));
}
if (!session?.accessToken) {
// eslint-disable-next-line no-console
console.warn('[withAuth] No accessToken — returning 401');
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
return handler(request, session, context);
});
}