Initial commit

This commit is contained in:
michaelswanson
2026-06-25 19:58:40 +00:00
commit 860d5f55cc
47 changed files with 19216 additions and 0 deletions
Executable
+5
View File
@@ -0,0 +1,5 @@
**/node_modules
**/.git
**/dist
server/data
*.md
Executable
+18
View File
@@ -0,0 +1,18 @@
APP_BASE_URL=https://eb.bitli.se
AUTH_SESSION_SECRET=replace-with-a-long-random-secret
# Engångstoken för att konfigurera OIDC via API innan auth är aktiverat.
# Skickas som headern "x-setup-token". Lämna tom om du konfigurerar via env-variablerna nedan.
SETUP_TOKEN=replace-with-a-long-random-value
# Pocket ID OIDC
OIDC_ISSUER=https://pocketid.example.com
OIDC_CLIENT_ID=replace-with-pocketid-client-id
OIDC_CLIENT_SECRET=replace-with-pocketid-client-secret
OIDC_REDIRECT_URI=https://eb.bitli.se/auth/callback
OIDC_SCOPE=openid profile email groups
# Optional allow-lists
OIDC_ALLOWED_GROUPS=
OIDC_ALLOWED_EMAILS=
OIDC_ALLOWED_DOMAINS=
+16
View File
@@ -0,0 +1,16 @@
node_modules/
dist/
.env
.env.*
!.env.example
server/data/
.claude/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
Executable
+23
View File
@@ -0,0 +1,23 @@
FROM node:20-alpine AS client-builder
WORKDIR /client
COPY client/package*.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
LABEL org.opencontainers.image.title="Enkelbudget" \
org.opencontainers.image.description="Enkel månadsbudget och lånehanterare" \
net.unraid.docker.webui="http://[IP]:[PORT:7842]" \
net.unraid.docker.icon="/mnt/user/noah/images/icons/enkelbudget.png" \
net.unraid.docker.managed="true"
COPY server/package*.json ./
RUN npm install --omit=dev
COPY server/ ./
COPY --from=client-builder /client/dist ./public
RUN mkdir -p /app/data && chmod +x /app/entrypoint.sh
EXPOSE 7842
CMD ["/app/entrypoint.sh"]
+9
View File
@@ -0,0 +1,9 @@
// Converts icon.svg → icon-192.png and icon-512.png using sharp
import sharp from 'sharp';
import { readFileSync } from 'fs';
const svg = readFileSync('public/icon.svg');
await sharp(svg).resize(192).png().toFile('public/icon-192.png');
await sharp(svg).resize(512).png().toFile('public/icon-512.png');
console.log('PWA icons generated from SVG.');
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="sv">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#2563EB" />
<meta name="description" content="Enkel månadsbudget och lånehanterare" />
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Enkelbudget" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Enkelbudget</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Generated Executable
+7457
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "enkelbudget-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"prebuild": "node generate-icons.mjs",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.378.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"sharp": "^0.33.5",
"tailwindcss": "^3.4.3",
"vite": "^5.2.11",
"vite-plugin-pwa": "^0.20.0",
"workbox-window": "^7.1.0"
},
"allowScripts": {
"esbuild@0.21.5": true,
"sharp@0.33.5": true
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Enkelbudget ikon">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0F172A" />
<stop offset="100%" stop-color="#2563EB" />
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0F172A" flood-opacity="0.16" />
</filter>
</defs>
<rect x="36" y="36" width="440" height="440" rx="112" fill="url(#bg)" filter="url(#shadow)" />
<g transform="translate(86 118)">
<rect x="30" y="82" width="280" height="180" rx="44" fill="#FFFFFF" />
<path d="M86 82V56c0-36 28-64 64-64h118c34 0 62 24 68 56h-44c-5-11-17-18-31-18H158c-15 0-28 12-28 26v26z" fill="#DCEAFE" />
<rect x="212" y="132" width="116" height="82" rx="28" fill="#E2E8F0" />
<circle cx="246" cy="173" r="13" fill="#0F172A" />
<circle cx="324" cy="92" r="28" fill="#22C55E" />
<path d="M313 92h22" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round" />
<path d="M324 81v22" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { Loader2, Shield } from 'lucide-react';
import { api } from './api.js';
import Header from './components/Header.jsx';
import AIPage from './pages/AIPage.jsx';
import BudgetPage from './pages/BudgetPage.jsx';
import LoansPage from './pages/LoansPage.jsx';
import SettingsPage from './pages/SettingsPage.jsx';
import TransactionsPage from './pages/TransactionsPage.jsx';
export default function App() {
const now = new Date();
const [auth, setAuth] = useState(null);
const currentPath = `${window.location.pathname}${window.location.search}`;
const returnTo = (
currentPath.startsWith('/api/')
|| currentPath.startsWith('/auth/')
|| currentPath.startsWith('/login')
|| currentPath.includes('returnTo=')
)
? `/budget/${now.getFullYear()}/${now.getMonth() + 1}`
: currentPath;
const loginUrl = `/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
useEffect(() => {
let active = true;
api.getAuthSession()
.then(result => {
if (active) setAuth(result);
})
.catch(() => {
if (active) {
setAuth({
enabled: false,
authenticated: false,
user: null,
});
}
});
return () => {
active = false;
};
}, []);
if (!auth) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3 text-slate-600">
<Loader2 size={20} className="animate-spin text-blue-600" />
<span>Laddar Enkelbudget...</span>
</div>
</div>
);
}
if (auth.enabled && !auth.authenticated) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
<div className="max-w-md w-full rounded-3xl border border-slate-200 bg-white shadow-sm px-6 py-7 text-center">
<div className="mx-auto mb-4 w-12 h-12 rounded-2xl bg-blue-100 text-blue-700 flex items-center justify-center">
<Shield size={22} />
</div>
<h1 className="text-xl font-semibold text-slate-900">Inloggning krävs</h1>
<p className="mt-2 text-sm text-slate-600">
Enkelbudget är skyddad med Pocket ID. Fortsätt till inloggningssidan för att använda appen.
</p>
<button
type="button"
onClick={() => window.location.assign(loginUrl)}
className="mt-5 inline-flex w-full items-center justify-center rounded-2xl bg-blue-600 px-4 py-3 text-sm font-medium text-white hover:bg-blue-700"
>
till inloggning
</button>
</div>
</div>
);
}
async function handleLogout() {
await api.logout();
window.location.assign('/login');
}
return (
<BrowserRouter>
<div className="min-h-screen min-h-dvh flex flex-col ios-safe-left ios-safe-right">
<Header user={auth.user} onLogout={handleLogout} />
<main className="flex-1 max-w-6xl w-full mx-auto px-4 pb-8 ios-safe-bottom">
<Routes>
<Route path="/" element={<Navigate to={`/budget/${now.getFullYear()}/${now.getMonth() + 1}`} replace />} />
<Route path="/login" element={<Navigate to={`/budget/${now.getFullYear()}/${now.getMonth() + 1}`} replace />} />
<Route path="/auth/callback" element={<Navigate to={`/budget/${now.getFullYear()}/${now.getMonth() + 1}`} replace />} />
<Route path="/enablebanking/auth_callback" element={<Navigate to="/installningar" replace />} />
<Route path="/budget/:year/:month" element={<BudgetPage />} />
<Route path="/ai" element={<AIPage />} />
<Route path="/transaktioner" element={<TransactionsPage />} />
<Route path="/lan" element={<LoansPage />} />
<Route path="/installningar" element={<SettingsPage />} />
<Route path="*" element={<Navigate to={`/budget/${now.getFullYear()}/${now.getMonth() + 1}`} replace />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
+81
View File
@@ -0,0 +1,81 @@
const BASE = '/api';
async function req(method, path, body, options = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { message: text };
}
}
if (res.status === 401 && !options.skipAuthRedirect) {
const returnTo = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/login?returnTo=${encodeURIComponent(returnTo)}`);
}
if (!res.ok) throw new Error(data?.error || data?.message || `API error ${res.status}`);
return data;
}
export const api = {
getAuthSession: () => req('GET', '/auth/session', undefined, { skipAuthRedirect: true }),
getAuthSettings: () => req('GET', '/auth/settings'),
updateAuthSettings: (data) => req('PUT', '/auth/settings', data),
logout: () => req('POST', '/auth/logout', {}),
getMonth: (year, month) => req('GET', `/months/${year}/${month}`),
getMonths: () => req('GET', '/months'),
getFxRates: () => req('GET', '/fx/latest'),
getCategories: () => req('GET', '/categories'),
addCategory: (data) => req('POST', '/categories', data),
updateCategory: (id, data) => req('PUT', `/categories/${id}`, data),
deleteCategory: (id) => req('DELETE', `/categories/${id}`),
getEnableBanking: () => req('GET', '/integrations/enable-banking'),
updateEnableBanking: (data) => req('PUT', '/integrations/enable-banking', data),
getEnableBankingAspsps: (country) => req('GET', `/integrations/enable-banking/aspsps?country=${encodeURIComponent(country)}`),
connectEnableBanking: (data) => req('POST', '/integrations/enable-banking/connect', data),
exchangeEnableBankingCode: (data) => req('POST', '/integrations/enable-banking/exchange', data),
syncEnableBanking: () => req('POST', '/integrations/enable-banking/sync', {}),
disconnectEnableBanking: () => req('DELETE', '/integrations/enable-banking/session'),
updateEnableBankingAccount: (accountId, data) => req('PUT', `/integrations/enable-banking/accounts/${encodeURIComponent(accountId)}`, data),
updateEnableBankingTransaction: (transactionId, data) => req('PUT', `/integrations/enable-banking/transactions/${encodeURIComponent(transactionId)}`, data),
suggestEnableBankingTransactionCategory: (transactionId) => req('POST', `/integrations/enable-banking/transactions/${encodeURIComponent(transactionId)}/suggest-category`, {}),
bulkSuggestEnableBankingTransactionCategory: (transactionId) => req('POST', `/integrations/enable-banking/transactions/${encodeURIComponent(transactionId)}/bulk-suggest-category`, {}),
getAiSettings: () => req('GET', '/ai/settings'),
updateAiSettings: (data) => req('PUT', '/ai/settings', data),
getAiModels: (baseUrl) => req('GET', `/ai/models${baseUrl ? `?base_url=${encodeURIComponent(baseUrl)}` : ''}`),
getAppSettings: () => req('GET', '/app/settings'),
updateAppSettings: (data) => req('PUT', '/app/settings', data),
testNtfyNotifications: (data) => req('POST', '/app/settings/test-ntfy', data || {}),
getAiConversations: () => req('GET', '/ai/conversations'),
createAiConversation: () => req('POST', '/ai/conversations', {}),
deleteAiConversation: (id) => req('DELETE', `/ai/conversations/${encodeURIComponent(id)}`),
chatWithAi: (data) => req('POST', '/ai/chat', data),
getSubscriptions: () => req('GET', '/subscriptions'),
addSubscription: (data) => req('POST', '/subscriptions', data),
updateSubscription: (id, data) => req('PUT', `/subscriptions/${id}`, data),
deleteSubscription: (id) => req('DELETE', `/subscriptions/${id}`),
addSubscriptionToMonth: (id, monthId) => req('POST', `/subscriptions/${id}/add-to-month`, { monthId }),
addMissingSubscriptionsToMonth: (monthId) => req('POST', '/subscriptions/add-missing-to-month', { monthId }),
addIncome: (monthId, data) => req('POST', `/months/${monthId}/income`, data),
updateIncome: (id, data) => req('PUT', `/income/${id}`, data),
deleteIncome: (id) => req('DELETE', `/income/${id}`),
addBill: (monthId, data) => req('POST', `/months/${monthId}/bills`, data),
updateBill: (id, data) => req('PUT', `/bills/${id}`, data),
toggleBill: (id) => req('PATCH', `/bills/${id}/toggle`, {}),
deleteBill: (id) => req('DELETE', `/bills/${id}`),
getLoans: () => req('GET', '/loans'),
addLoan: (data) => req('POST', '/loans', data),
updateLoan: (id, data) => req('PUT', `/loans/${id}`, data),
deleteLoan: (id) => req('DELETE', `/loans/${id}`),
getLoanPayments: (id) => req('GET', `/loans/${id}/payments`),
addLoanPayment: (id, data) => req('POST', `/loans/${id}/payments`, data),
};
+394
View File
@@ -0,0 +1,394 @@
import { Bot, RefreshCw, Sparkles } from 'lucide-react';
import { useEffect, useState } from 'react';
const DRAFT_KEY = 'enkelbudget-ai-settings-draft';
const SYSTEM_PROMPT_PRESETS = [
{
id: 'balanced',
label: 'Balanserad',
description: 'Bra standard för vardagliga köpbeslut.',
prompt: 'Var tydlig, kort och noggrann. Utgå alltid först från faktiskt kontosaldo, dagar till nästa inkomst och utgifter innan dess. När användaren bifogar bild ska du identifiera produkt, pris, valuta och period först. Visa alltid uträkningen rad för rad, summera korrekt och avsluta med en tydlig bedömning: klokt, tveksamt eller dålig idé.',
},
{
id: 'strict',
label: 'Strikt',
description: 'Extra hård mot impulsköp och små marginaler.',
prompt: 'Var extra strikt med impulsköp. Anta att nöjesköp ska avrådas från om de försämrar marginalen före nästa inkomst. Visa alltid risker först, sedan eventuell anledning till varför köpet ändå kan vara okej. Utgå från faktiskt kontosaldo och kommande dagar, inte teoretisk månadsbudget.',
},
{
id: 'blunt',
label: 'Brutalt ärlig',
description: 'Rak, osentimental och utan fluff.',
prompt: 'Var rak och osentimental. Om köpet är onödigt eller riskabelt ska du säga nej tydligt. Prioritera likviditet, räkningar och buffert över nöjen. Undvik fluff, visa uträkningen först och gå sedan rakt på slutsatsen.',
},
{
id: 'coach',
label: 'Pedagogisk coach',
description: 'Lugn och förklarande ton.',
prompt: 'Svara som en lugn och tydlig ekonomicoach. Förklara enkelt vad som händer med saldot före nästa inkomst, vilka utgifter som konkurrerar om pengarna och om köpet känns klokt, tveksamt eller dumt just nu. Visa gärna uträkningen steg för steg.',
},
{
id: 'image',
label: 'Bildfokus',
description: 'Bra för butiksskärmdumpar och prisbilder.',
prompt: 'När användaren bifogar bild ska du alltid börja med att identifiera produkt, pris, valuta och period från bilden. Visa sedan en tydlig uträkning: köp, andra nämnda utgifter, kvarvarande saldo och risk före nästa inkomst. Om något inte går att läsa i bilden ska du säga det tydligt.',
},
{
id: 'adhd',
label: 'Kort och snabb',
description: 'Mer komprimerade svar med tydlig slutsats.',
prompt: 'Svara kort, tydligt och konkret. Börja med slutsatsen i en mening. Visa sedan bara de viktigaste siffrorna: saldo nu, köp, andra utgifter, kvar före nästa inkomst och om det är klokt eller inte.',
},
];
function hasVisionCapability(model) {
return Array.isArray(model?.capabilities) && model.capabilities.includes('vision');
}
function ModelSelect({ label, value, onChange, models, placeholder }) {
return (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">{label}</label>
<select
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={value}
onChange={event => onChange(event.target.value)}
>
<option value="">{placeholder}</option>
{models.map(model => (
<option key={model.value || model.name} value={model.value || model.name}>
{model.label || model.name}
</option>
))}
</select>
</div>
);
}
function buildFormState(settings) {
return {
enabled: settings?.enabled ?? false,
baseUrl: settings?.base_url ?? 'http://host.docker.internal:11434',
model: settings?.model ?? '',
visionModel: settings?.vision_model ?? '',
systemPrompt: settings?.system_prompt ?? '',
includeBudgetContext: settings?.include_budget_context ?? true,
includeBankingContext: settings?.include_banking_context ?? true,
};
}
function loadDraft() {
try {
const raw = localStorage.getItem(DRAFT_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function persistDraft(state) {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(state));
} catch {
// ignore localStorage issues
}
}
function clearDraft() {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
// ignore localStorage issues
}
}
export default function AISettingsSection({
settings,
models,
busy,
loadingModels,
error,
modelError,
saveMessage,
saveToken,
onSave,
onRefreshModels,
}) {
const [formState, setFormState] = useState(() => loadDraft() || buildFormState(settings));
const [validationError, setValidationError] = useState('');
const [selectedPresetId, setSelectedPresetId] = useState('');
useEffect(() => {
const draft = loadDraft();
if (draft) {
setFormState(draft);
return;
}
setFormState(buildFormState(settings));
}, [settings]);
useEffect(() => {
persistDraft(formState);
}, [formState]);
useEffect(() => {
if (!saveToken) return;
clearDraft();
setFormState(buildFormState(settings));
}, [saveToken, settings]);
function updateField(patch) {
setValidationError('');
setFormState(current => ({ ...current, ...patch }));
}
function handleSubmit(event) {
event.preventDefault();
const selectedVisionModel = models.find(model => model.name === formState.visionModel);
if (formState.visionModel && !hasVisionCapability(selectedVisionModel)) {
setValidationError(`Modellen ${formState.visionModel} verkar inte ha bildstöd. Välj en vision-modell som faktiskt har capability "vision", till exempel qwen3.5:9b.`);
return;
}
onSave({
enabled: formState.enabled,
base_url: formState.baseUrl.trim(),
model: formState.model,
vision_model: formState.visionModel,
system_prompt: formState.systemPrompt,
include_budget_context: formState.includeBudgetContext,
include_banking_context: formState.includeBankingContext,
});
}
function applyPreset(presetId) {
const preset = SYSTEM_PROMPT_PRESETS.find(item => item.id === presetId);
if (!preset) return;
setSelectedPresetId(presetId);
updateField({ systemPrompt: preset.prompt });
}
const selectedVisionModel = models.find(model => model.name === formState.visionModel) || null;
const selectedVisionModelIsValid = !formState.visionModel || hasVisionCapability(selectedVisionModel);
const visionModels = models.filter(hasVisionCapability);
const visionModelOptions = selectedVisionModelIsValid
? visionModels
: [
{
name: formState.visionModel,
value: formState.visionModel,
label: `${formState.visionModel} (saknar bildstöd)`,
},
...visionModels,
];
return (
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden mb-3">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div className="flex items-center gap-2">
<Bot size={16} className="text-blue-600" />
<span className="font-semibold text-slate-700 text-sm uppercase tracking-wide">AI / Ollama</span>
</div>
<div className="flex items-center gap-2">
{settings?.enabled && (
<span className="text-xs px-2.5 py-1 rounded-full font-medium bg-emerald-100 text-emerald-700">
Aktiv
</span>
)}
{(settings?.model || settings?.vision_model) && (
<span className="text-xs px-2.5 py-1 rounded-full font-medium bg-violet-100 text-violet-700">
Modeller valda
</span>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="px-4 py-4 space-y-4">
<div className="rounded-2xl border border-blue-100 bg-gradient-to-br from-blue-50 via-indigo-50 to-white px-4 py-4">
<div className="flex items-start gap-3">
<div className="rounded-2xl bg-white/90 p-2 text-blue-600 shadow-sm">
<Sparkles size={18} />
</div>
<div>
<div className="text-sm font-semibold text-slate-900">Lokal AI som kan läsa din ekonomi</div>
<p className="mt-1 text-sm text-slate-600">
Peka Enkelbudget mot din Ollama-server och välj en vanlig modell för text samt en vision-modell för skärmdumpar och kvitton.
</p>
</div>
</div>
</div>
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
{error}
</div>
)}
{saveMessage && !error && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm text-emerald-700">
{saveMessage}
</div>
)}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formState.enabled}
onChange={event => updateField({ enabled: event.target.checked })}
className="rounded w-4 h-4 accent-blue-600"
/>
<span className="text-sm text-slate-700">Aktivera Ollama-integrationen</span>
</label>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Ollama base URL</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.baseUrl}
onChange={event => updateField({ baseUrl: event.target.value })}
placeholder="http://host.docker.internal:11434"
/>
<p className="mt-1 text-xs text-slate-500">
Om backend kör i Docker är `http://host.docker.internal:11434` ofta rätt när Ollama körs lokalt samma maskin.
</p>
</div>
<div className="flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
<div>
<div className="text-sm font-medium text-slate-800">Tillgängliga modeller</div>
<div className="text-xs text-slate-500 mt-1">
Hämta från din Ollama-server du slipper skriva modellnamn känsla.
</div>
</div>
<button
type="button"
onClick={() => onRefreshModels(formState.baseUrl.trim())}
className="inline-flex items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-50"
disabled={loadingModels}
>
<RefreshCw size={14} className={loadingModels ? 'animate-spin' : ''} />
{loadingModels ? 'Laddar...' : 'Ladda modeller'}
</button>
</div>
{(modelError || validationError) && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-800">
{validationError || modelError}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ModelSelect
label="Textmodell"
value={formState.model}
onChange={value => updateField({ model: value })}
models={models}
placeholder="Välj modell för frågor och resonemang"
/>
<ModelSelect
label="Vision-modell"
value={formState.visionModel}
onChange={value => updateField({ visionModel: value })}
models={visionModelOptions}
placeholder="Välj modell för bilder/skärmdumpar"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-xs text-slate-600">
Textmodellen används för vanliga frågor. För snabbast svar är en mindre modell ofta bäst, till exempel `budget-snabb:latest`.
</div>
<div className={`rounded-xl border px-3 py-3 text-xs ${
selectedVisionModelIsValid
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-amber-200 bg-amber-50 text-amber-800'
}`}>
{selectedVisionModelIsValid
? `Bildstöd klart. ${visionModels.length} vision-modell${visionModels.length === 1 ? '' : 'er'} hittades på din Ollama-server.`
: 'Nu vald vision-modell saknar bildstöd. Byt till en riktig vision-modell innan du sparar, till exempel qwen3.5:9b.'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="flex items-center gap-2 cursor-pointer rounded-xl border border-slate-200 px-3 py-3">
<input
type="checkbox"
checked={formState.includeBudgetContext}
onChange={event => updateField({ includeBudgetContext: event.target.checked })}
className="rounded w-4 h-4 accent-blue-600"
/>
<div>
<div className="text-sm font-medium text-slate-700">Skicka budgetkontext</div>
<div className="text-xs text-slate-500">Inkomster, räkningar och prenumerationer</div>
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer rounded-xl border border-slate-200 px-3 py-3">
<input
type="checkbox"
checked={formState.includeBankingContext}
onChange={event => updateField({ includeBankingContext: event.target.checked })}
className="rounded w-4 h-4 accent-blue-600"
/>
<div>
<div className="text-sm font-medium text-slate-700">Skicka bankkontext</div>
<div className="text-xs text-slate-500">Kontosaldon och nyliga transaktioner</div>
</div>
</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Extra systemprompt</label>
<div className="mb-3 rounded-2xl border border-slate-200 bg-slate-50 px-3 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">Färdiga stilar</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<select
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={selectedPresetId}
onChange={event => setSelectedPresetId(event.target.value)}
>
<option value="">Välj en preset för AI-ton och bedömning</option>
{SYSTEM_PROMPT_PRESETS.map(preset => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
<button
type="button"
onClick={() => applyPreset(selectedPresetId)}
disabled={!selectedPresetId}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-50"
>
Använd preset
</button>
</div>
{selectedPresetId && (
<div className="mt-2 text-xs text-slate-500">
{SYSTEM_PROMPT_PRESETS.find(preset => preset.id === selectedPresetId)?.description}
</div>
)}
</div>
<textarea
className="w-full min-h-[110px] border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.systemPrompt}
onChange={event => updateField({ systemPrompt: event.target.value })}
placeholder="Exempel: Var extra strikt med impulsinköp och visa alltid risker först."
/>
<p className="mt-1 text-xs text-slate-500">
Välj en preset som startpunkt och finjustera sedan texten själv om du vill.
</p>
</div>
<button
type="submit"
className="w-full sm:w-auto rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
disabled={busy}
>
Spara AI-inställningar
</button>
</form>
</section>
);
}
+555
View File
@@ -0,0 +1,555 @@
import { BellRing, Landmark, MessageSquareMore, Pencil, WalletCards } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import Modal from './Modal.jsx';
function buildFormState(settings) {
return {
salaryDayOfMonth: settings?.finance_profile?.salary_day_of_month ?? 25,
bufferDaysTarget: settings?.finance_profile?.buffer_days_target ?? 7,
salaryAccountUid: settings?.finance_profile?.salary_account_uid ?? '',
recurringIncomeNote: settings?.finance_profile?.recurring_income_note ?? '',
primaryAccountUid: settings?.account_view?.primary_account_uid ?? '',
includeSavingsInAi: settings?.account_view?.include_savings_in_ai ?? false,
hideZeroBalanceAccounts: settings?.account_view?.hide_zero_balance_accounts ?? false,
ntfyEnabled: settings?.notifications?.ntfy_enabled ?? false,
ntfyBaseUrl: settings?.notifications?.ntfy_base_url ?? 'https://ntfy.sh',
ntfyTopic: settings?.notifications?.ntfy_topic ?? '',
ntfyTitle: settings?.notifications?.ntfy_title ?? 'Enkelbudget',
ntfyTags: settings?.notifications?.ntfy_tags ?? 'money_with_wings,bank',
ntfyClickUrl: settings?.notifications?.ntfy_click_url ?? '',
ntfyPriority: settings?.notifications?.ntfy_priority ?? 3,
ntfyAccessToken: '',
clearNtfyAccessToken: false,
notifyNewTransactions: settings?.notifications?.notify_new_transactions ?? true,
includePendingTransactions: settings?.notifications?.include_pending_transactions ?? false,
minimumTransactionAmount: settings?.notifications?.minimum_transaction_amount ?? 0,
};
}
function accountLabel(account) {
return account?.display_name || account?.alias || account?.name || account?.uid || 'Konto';
}
function SettingsForm({ initial, accounts, busy, testingNtfy, onSave, onTestNtfy, onClose }) {
const [formState, setFormState] = useState(() => buildFormState(initial));
useEffect(() => {
setFormState(buildFormState(initial));
}, [initial]);
function updateField(patch) {
setFormState(current => ({ ...current, ...patch }));
}
function buildPayload() {
return {
finance_profile: {
salary_day_of_month: parseInt(formState.salaryDayOfMonth, 10) || 25,
buffer_days_target: parseInt(formState.bufferDaysTarget, 10) || 7,
salary_account_uid: formState.salaryAccountUid,
recurring_income_note: formState.recurringIncomeNote.trim(),
},
account_view: {
primary_account_uid: formState.primaryAccountUid,
include_savings_in_ai: formState.includeSavingsInAi,
hide_zero_balance_accounts: formState.hideZeroBalanceAccounts,
},
notifications: {
ntfy_enabled: formState.ntfyEnabled,
ntfy_base_url: formState.ntfyBaseUrl.trim(),
ntfy_topic: formState.ntfyTopic.trim(),
ntfy_title: formState.ntfyTitle.trim(),
ntfy_tags: formState.ntfyTags.trim(),
ntfy_click_url: formState.ntfyClickUrl.trim(),
ntfy_priority: Number(formState.ntfyPriority) || 3,
ntfy_access_token: formState.ntfyAccessToken.trim(),
clear_ntfy_access_token: formState.clearNtfyAccessToken,
notify_new_transactions: formState.notifyNewTransactions,
include_pending_transactions: formState.includePendingTransactions,
minimum_transaction_amount: Number(formState.minimumTransactionAmount) || 0,
},
};
}
function handleSubmit(event) {
event.preventDefault();
onSave(buildPayload());
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="rounded-2xl border border-emerald-100 bg-emerald-50/80 px-4 py-4">
<div className="flex items-start gap-3">
<div className="rounded-2xl bg-white p-2 text-emerald-600 shadow-sm">
<WalletCards size={18} />
</div>
<div>
<div className="text-sm font-semibold text-slate-900">Styr hur appen tänker om dina pengar</div>
<p className="mt-1 text-sm text-slate-600">
Här väljer du vilket konto som är viktigast, när lönen brukar komma och om Enkelbudget ska skicka snabba `ntfy`-notiser när nya transaktioner dyker upp.
</p>
</div>
</div>
</div>
<div className="grid gap-5 lg:grid-cols-2">
<div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-4">
<div className="flex items-center gap-2">
<Landmark size={16} className="text-blue-600" />
<div className="text-sm font-semibold text-slate-800">Löneprofil</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Lönedag i månaden</label>
<input
type="number"
min="1"
max="31"
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.salaryDayOfMonth}
onChange={event => updateField({ salaryDayOfMonth: event.target.value })}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Buffertmål (dagar)</label>
<input
type="number"
min="0"
max="60"
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.bufferDaysTarget}
onChange={event => updateField({ bufferDaysTarget: event.target.value })}
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Lönekonto</label>
<select
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.salaryAccountUid}
onChange={event => updateField({ salaryAccountUid: event.target.value })}
>
<option value="">Välj konto</option>
{accounts.map(account => (
<option key={account.uid} value={account.uid}>
{accountLabel(account)}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Återkommande inkomst / notis</label>
<input
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.recurringIncomeNote}
onChange={event => updateField({ recurringIncomeNote: event.target.value })}
placeholder="Till exempel: Barnbidrag 1325 kr den 18:e"
/>
<p className="mt-1 text-xs text-slate-500">
Hjälper AI:n att väga in återkommande pengar även om de inte syns som lön i appen.
</p>
</div>
</div>
<div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-4">
<div className="flex items-center gap-2">
<WalletCards size={16} className="text-violet-600" />
<div className="text-sm font-semibold text-slate-800">Kontovisning</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Primärt konto</label>
<select
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.primaryAccountUid}
onChange={event => updateField({ primaryAccountUid: event.target.value })}
>
<option value="">Låt appen välja automatiskt</option>
{accounts.map(account => (
<option key={account.uid} value={account.uid}>
{accountLabel(account)}
</option>
))}
</select>
<p className="mt-1 text-xs text-slate-500">
Det här kontot prioriteras i AI-analys och i visningen av likviditet.
</p>
</div>
<label className="flex items-start gap-3 rounded-xl border border-slate-200 bg-white px-3 py-3">
<input
type="checkbox"
checked={formState.includeSavingsInAi}
onChange={event => updateField({ includeSavingsInAi: event.target.checked })}
className="mt-0.5 h-4 w-4 rounded accent-blue-600"
/>
<div>
<div className="text-sm font-medium text-slate-700">Ta med sparkonton i AI-bedömningar</div>
<div className="text-xs text-slate-500">Stäng av om AI:n bara ska räkna vardagskassan.</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-xl border border-slate-200 bg-white px-3 py-3">
<input
type="checkbox"
checked={formState.hideZeroBalanceAccounts}
onChange={event => updateField({ hideZeroBalanceAccounts: event.target.checked })}
className="mt-0.5 h-4 w-4 rounded accent-blue-600"
/>
<div>
<div className="text-sm font-medium text-slate-700">Dölj konton utan saldo i kontovisningen</div>
<div className="text-xs text-slate-500">Gör framför allt Transaktioner-vyn renare mobil och PWA.</div>
</div>
</label>
</div>
</div>
<div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-4">
<div className="flex items-center gap-2">
<BellRing size={16} className="text-sky-600" />
<div className="text-sm font-semibold text-slate-800">ntfy-notiser</div>
</div>
<label className="flex items-start gap-3 rounded-xl border border-slate-200 bg-white px-3 py-3">
<input
type="checkbox"
checked={formState.ntfyEnabled}
onChange={event => updateField({ ntfyEnabled: event.target.checked })}
className="mt-0.5 h-4 w-4 rounded accent-blue-600"
/>
<div>
<div className="text-sm font-medium text-slate-700">Aktivera ntfy</div>
<div className="text-xs text-slate-500">Skicka pushnotiser via ntfy när nya banktransaktioner hittas vid sync.</div>
</div>
</label>
<div className="grid gap-3 lg:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">ntfy-server</label>
<input
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.ntfyBaseUrl}
onChange={event => updateField({ ntfyBaseUrl: event.target.value })}
placeholder="https://ntfy.sh"
/>
<p className="mt-1 text-xs text-slate-500">
Fungerar med `https://ntfy.sh` eller din egen self-hostade ntfy-server.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Topic</label>
<input
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.ntfyTopic}
onChange={event => updateField({ ntfyTopic: event.target.value })}
placeholder="enkelbudget-michael-xyz123"
/>
<p className="mt-1 text-xs text-slate-500">
Välj gärna ett långt, svårt topic-namn om du kör publika `ntfy.sh`.
</p>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Titel i notisen</label>
<input
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.ntfyTitle}
onChange={event => updateField({ ntfyTitle: event.target.value })}
placeholder="Enkelbudget"
/>
<p className="mt-1 text-xs text-slate-500">
Du kan skriva vanlig text eller emojis, till exempel `💸 Enkelbudget`.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Emoji / tags</label>
<input
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.ntfyTags}
onChange={event => updateField({ ntfyTags: event.target.value })}
placeholder="money_with_wings,bank"
/>
<p className="mt-1 text-xs text-slate-500">
Kommaseparerade ntfy-tags. Exempel: `money_with_wings,bank,warning` eller `white_check_mark`.
</p>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px]">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Klick-URL</label>
<input
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.ntfyClickUrl}
onChange={event => updateField({ ntfyClickUrl: event.target.value })}
placeholder="https://eb.bitli.se/transaktioner"
/>
<p className="mt-1 text-xs text-slate-500">
När du trycker notisen kan den öppna en sida direkt, till exempel Transaktioner-fliken.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Prioritet</label>
<select
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.ntfyPriority}
onChange={event => updateField({ ntfyPriority: event.target.value })}
>
<option value="1">1 - Låg</option>
<option value="2">2 - Normal</option>
<option value="3">3 - Standard</option>
<option value="4">4 - Hög</option>
<option value="5">5 - Viktig</option>
</select>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Access token</label>
<input
type="password"
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.ntfyAccessToken}
onChange={event => updateField({
ntfyAccessToken: event.target.value,
clearNtfyAccessToken: false,
})}
placeholder={initial?.notifications?.has_ntfy_access_token ? 'Sparad på serversidan' : 'Valfritt om servern kräver auth'}
/>
{initial?.notifications?.has_ntfy_access_token && (
<label className="mt-2 flex items-center gap-2 text-xs text-slate-600">
<input
type="checkbox"
checked={formState.clearNtfyAccessToken}
onChange={event => updateField({ clearNtfyAccessToken: event.target.checked })}
/>
Rensa sparad access token
</label>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-start gap-3 rounded-xl border border-slate-200 bg-white px-3 py-3">
<input
type="checkbox"
checked={formState.notifyNewTransactions}
onChange={event => updateField({ notifyNewTransactions: event.target.checked })}
className="mt-0.5 h-4 w-4 rounded accent-blue-600"
/>
<div>
<div className="text-sm font-medium text-slate-700">Notis för nya transaktioner</div>
<div className="text-xs text-slate-500">Skickas när sync hittar nya poster som inte fanns tidigare.</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-xl border border-slate-200 bg-white px-3 py-3">
<input
type="checkbox"
checked={formState.includePendingTransactions}
onChange={event => updateField({ includePendingTransactions: event.target.checked })}
className="mt-0.5 h-4 w-4 rounded accent-blue-600"
/>
<div>
<div className="text-sm font-medium text-slate-700">Ta med väntande</div>
<div className="text-xs text-slate-500">Bra om du vill bli pingad redan innan posten är bokförd.</div>
</div>
</label>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Minsta belopp för notis</label>
<input
type="number"
min="0"
step="1"
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formState.minimumTransactionAmount}
onChange={event => updateField({ minimumTransactionAmount: event.target.value })}
/>
<p className="mt-1 text-xs text-slate-500">Använd 0 för att allt. Beloppet jämförs absolutvärdet.</p>
</div>
<div className="rounded-xl border border-sky-100 bg-sky-50 px-3 py-3 text-xs leading-5 text-sky-900">
iPhone är native `ntfy`-appen oftast det bästa valet för snabba pushar. Här kan du även styra titel, emoji/tags och vilken URL notisen ska öppna när du trycker den.
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-slate-500">
{initial?.notifications?.last_sent_at
? `Senaste testsignal/notis: ${new Date(initial.notifications.last_sent_at).toLocaleString('sv-SE')}`
: 'Ingen ntfy-notis skickad ännu.'}
</div>
<button
type="button"
onClick={() => onTestNtfy(buildPayload())}
disabled={testingNtfy || busy}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-sky-200 bg-sky-50 px-3 py-2 text-sm font-medium text-sky-700 hover:bg-sky-100 disabled:opacity-50"
>
<MessageSquareMore size={15} />
{testingNtfy ? 'Skickar test...' : 'Testa ntfy'}
</button>
</div>
</div>
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Avbryt
</button>
<button
type="submit"
disabled={busy}
className="flex-1 rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Spara appinställningar
</button>
</div>
</form>
);
}
export default function AppSettingsSection({
settings,
accounts,
busy,
testingNtfy,
error,
message,
onSave,
onTestNtfy,
}) {
const [isOpen, setIsOpen] = useState(false);
const accountOptions = useMemo(
() => (Array.isArray(accounts) ? accounts : []).map(account => ({
uid: account.uid,
name: accountLabel(account),
})),
[accounts]
);
const primaryAccountName = accountOptions.find(account => account.uid === settings?.account_view?.primary_account_uid)?.name || 'Automatiskt val';
const salaryAccountName = accountOptions.find(account => account.uid === settings?.finance_profile?.salary_account_uid)?.name || 'Inte valt';
return (
<section className="mb-3 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<div className="flex items-center gap-2">
<WalletCards size={16} className="text-emerald-600" />
<span className="text-sm font-semibold uppercase tracking-wide text-slate-700">Appinställningar</span>
</div>
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700"
>
<Pencil size={14} />
Konfigurera
</button>
</div>
<div className="border-b border-slate-100 bg-emerald-50/70 px-4 py-3 text-sm text-slate-600">
Här styr du hur Enkelbudget ska tänka om lön, huvudkonto, sparkonton och notiser.
</div>
<div className="space-y-3 px-4 py-4">
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
{error}
</div>
)}
{message && !error && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm text-emerald-700">
{message}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-xs text-slate-500">Lönedag</div>
<div className="mt-1 text-sm font-medium text-slate-800">Dag {settings?.finance_profile?.salary_day_of_month ?? 25}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-xs text-slate-500">Buffertmål</div>
<div className="mt-1 text-sm font-medium text-slate-800">{settings?.finance_profile?.buffer_days_target ?? 7} dagar</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-xs text-slate-500">Lönekonto</div>
<div className="mt-1 text-sm font-medium text-slate-800">{salaryAccountName}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-xs text-slate-500">Primärt konto</div>
<div className="mt-1 text-sm font-medium text-slate-800">{primaryAccountName}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-xs text-slate-500">Sparkonton i AI</div>
<div className="mt-1 text-sm font-medium text-slate-800">{settings?.account_view?.include_savings_in_ai ? 'Ja' : 'Nej'}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-xs text-slate-500">ntfy</div>
<div className="mt-1 text-sm font-medium text-slate-800">{settings?.notifications?.ntfy_enabled ? 'Aktiverat' : 'Avstängt'}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-xs text-slate-500">Notistitel</div>
<div className="mt-1 text-sm font-medium text-slate-800">{settings?.notifications?.ntfy_title || 'Enkelbudget'}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-xs text-slate-500">ntfy-tags</div>
<div className="mt-1 text-sm font-medium text-slate-800">{settings?.notifications?.ntfy_tags || 'Inga taggar'}</div>
</div>
</div>
{settings?.finance_profile?.recurring_income_note && (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-700">
<span className="font-medium text-slate-800">Återkommande inkomst:</span> {settings.finance_profile.recurring_income_note}
</div>
)}
{(settings?.notifications?.last_error || settings?.notifications?.ntfy_topic) && (
<div className="rounded-xl border border-slate-200 px-3 py-3 text-sm text-slate-700">
<div className="mb-2 font-medium text-slate-800">ntfy-diagnostik</div>
{settings?.notifications?.ntfy_base_url && (
<div className="text-slate-600">Server: {settings.notifications.ntfy_base_url}</div>
)}
{settings?.notifications?.ntfy_topic && (
<div className="text-slate-600">Topic: {settings.notifications.ntfy_topic}</div>
)}
{settings?.notifications?.ntfy_click_url && (
<div className="text-slate-600 break-all">Klick-URL: {settings.notifications.ntfy_click_url}</div>
)}
{settings?.notifications?.last_error ? (
<div className="mt-2 text-amber-700">Senaste fel: {settings.notifications.last_error}</div>
) : (
<div className="mt-2 text-emerald-700">Ingen ntfy-felkedja sparad.</div>
)}
</div>
)}
</div>
{isOpen && (
<Modal title="Appinställningar" maxWidthClass="sm:max-w-4xl" onClose={() => setIsOpen(false)}>
<SettingsForm
initial={settings}
accounts={accountOptions}
busy={busy}
testingNtfy={testingNtfy}
onSave={onSave}
onTestNtfy={onTestNtfy}
onClose={() => setIsOpen(false)}
/>
</Modal>
)}
</section>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { useState } from 'react';
import { Plus, Pencil, Trash2, CheckCircle2, Circle } from 'lucide-react';
import { fmt, parseAmount } from '../utils.js';
import Modal from './Modal.jsx';
function BillForm({ initial, categories, defaultCategoryId, onSave, onClose }) {
const [name, setName] = useState(initial?.name ?? '');
const [amount, setAmount] = useState(initial?.amount ?? '');
const [catId, setCatId] = useState(initial?.category_id ?? defaultCategoryId ?? categories[0]?.id ?? '');
const [isPaid, setIsPaid] = useState(initial?.is_paid ?? false);
const [notes, setNotes] = useState(initial?.notes ?? '');
function submit(e) {
e.preventDefault();
if (!name.trim() || isNaN(parseAmount(amount)) || !catId) return;
onSave({
name: name.trim(),
amount: parseAmount(amount),
category_id: parseInt(catId),
is_paid: isPaid ? 1 : 0,
notes: notes.trim() || null,
});
onClose();
}
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Namn</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={name} onChange={e => setName(e.target.value)} placeholder="t.ex. Ellevio" required autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Belopp (kr)</label>
<input
type="text" inputMode="decimal"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={amount} onChange={e => setAmount(e.target.value)} placeholder="0" required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategori</label>
<select
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={catId} onChange={e => setCatId(e.target.value)}
>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Anteckning (valfri)</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={notes} onChange={e => setNotes(e.target.value)} placeholder="Valfri notering"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isPaid} onChange={e => setIsPaid(e.target.checked)}
className="rounded w-4 h-4 accent-green-500" />
<span className="text-sm text-slate-700">Markera som betald</span>
</label>
<div className="flex gap-2 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-600 hover:bg-slate-50">
Avbryt
</button>
<button type="submit" className="flex-1 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
Spara
</button>
</div>
</form>
);
}
export default function CategorySection({ category, bills, allCategories, monthId, onAdd, onUpdate, onToggle, onDelete }) {
const [modal, setModal] = useState(null);
const total = bills.reduce((s, b) => s + b.amount, 0);
const paidCount = bills.filter(b => b.is_paid).length;
return (
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden mb-3">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: category.color }}
/>
<span className="font-semibold text-slate-700 text-sm uppercase tracking-wide">{category.name}</span>
{bills.length > 0 && (
<span className="text-xs text-slate-400">
{paidCount}/{bills.length} betald{bills.length !== 1 ? 'a' : ''}
</span>
)}
</div>
<button
onClick={() => setModal('add')}
className="flex items-center gap-1 text-blue-600 text-xs font-medium hover:text-blue-700"
>
<Plus size={14} /> Lägg till
</button>
</div>
<div className="divide-y divide-slate-50">
{bills.map(bill => (
<div key={bill.id} className={`flex items-center gap-3 px-4 py-2.5 group transition-colors ${bill.is_paid ? 'bg-green-50/30' : ''}`}>
<button
onClick={() => onToggle(bill.id)}
className="shrink-0 text-slate-300 hover:text-green-500 transition-colors"
>
{bill.is_paid
? <CheckCircle2 size={18} className="text-green-500" />
: <Circle size={18} />}
</button>
<span className={`flex-1 text-sm truncate ${bill.is_paid ? 'text-slate-400 line-through' : 'text-slate-700'}`}>
{bill.name}
</span>
{bill.notes && (
<span className="text-xs text-slate-400 truncate max-w-[80px] hidden sm:block">{bill.notes}</span>
)}
<span className={`text-sm font-semibold shrink-0 ${bill.is_paid ? 'text-slate-400' : 'text-slate-800'}`}>
{fmt(bill.amount)}
</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={() => setModal({ edit: bill })} className="p-1 rounded hover:bg-slate-100 text-slate-400">
<Pencil size={13} />
</button>
<button onClick={() => onDelete(bill.id)} className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-500">
<Trash2 size={13} />
</button>
</div>
</div>
))}
{bills.length === 0 && (
<p className="px-4 py-3 text-sm text-slate-400 italic">Inga poster tillagda</p>
)}
</div>
{bills.length > 0 && (
<div className="px-4 py-2.5 bg-slate-50 border-t border-slate-100 flex justify-end items-center gap-2">
<span className="text-xs text-slate-500">Summa {category.name}</span>
<span className="font-bold text-slate-700">{fmt(total)}</span>
</div>
)}
{modal === 'add' && (
<Modal title={`Lägg till i ${category.name}`} onClose={() => setModal(null)}>
<BillForm
categories={allCategories}
defaultCategoryId={category.id}
onSave={(data) => onAdd(monthId, data)}
onClose={() => setModal(null)}
/>
</Modal>
)}
{modal?.edit && (
<Modal title="Redigera post" onClose={() => setModal(null)}>
<BillForm
initial={modal.edit}
categories={allCategories}
defaultCategoryId={modal.edit.category_id}
onSave={(data) => onUpdate(modal.edit.id, data)}
onClose={() => setModal(null)}
/>
</Modal>
)}
</section>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useState } from 'react';
import { FolderCog, Pencil, Plus, Trash2 } from 'lucide-react';
import Modal from './Modal.jsx';
const PRESET_COLORS = ['#7C3AED', '#2563EB', '#D97706', '#059669', '#DC2626', '#0F766E', '#9333EA', '#475569'];
function CategoryForm({ initial, suggestedSortOrder, onSave, onClose }) {
const [name, setName] = useState(initial?.name ?? '');
const [color, setColor] = useState(initial?.color ?? PRESET_COLORS[0]);
const [sortOrder, setSortOrder] = useState(initial?.sort_order ?? suggestedSortOrder ?? 1);
function submit(e) {
e.preventDefault();
if (!name.trim()) return;
onSave({
name: name.trim(),
color,
sort_order: parseInt(sortOrder) || suggestedSortOrder || 1,
});
onClose();
}
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Namn</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={name}
onChange={e => setName(e.target.value)}
placeholder="t.ex. Försäkringar"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Färg</label>
<div className="flex flex-wrap gap-2">
{PRESET_COLORS.map(option => (
<button
key={option}
type="button"
onClick={() => setColor(option)}
className={`w-8 h-8 rounded-full border-2 ${color === option ? 'border-slate-800' : 'border-transparent'}`}
style={{ backgroundColor: option }}
aria-label={`Välj färg ${option}`}
/>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Sorteringsordning</label>
<input
type="number"
min="1"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={sortOrder}
onChange={e => setSortOrder(e.target.value)}
/>
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-600 hover:bg-slate-50">
Avbryt
</button>
<button type="submit" className="flex-1 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
Spara
</button>
</div>
</form>
);
}
export default function CategorySettingsSection({ categories, onAdd, onUpdate, onDelete, deleteError }) {
const [modal, setModal] = useState(null);
const suggestedSortOrder = (categories.at(-1)?.sort_order ?? 0) + 1;
return (
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden mb-3">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div className="flex items-center gap-2">
<FolderCog size={16} className="text-emerald-600" />
<span className="font-semibold text-slate-700 text-sm uppercase tracking-wide">Kategorier</span>
</div>
<button
onClick={() => setModal('add')}
className="flex items-center gap-1 text-blue-600 text-xs font-medium hover:text-blue-700"
>
<Plus size={14} /> Lägg till
</button>
</div>
<div className="px-4 py-3 border-b border-slate-100 bg-emerald-50/70 text-sm text-slate-600">
Här skapar och ändrar du kategorierna som används i budgeten, transaktionerna och AI-förslagen. Byter du namn eller färg här följer det med i hela appen.
</div>
{deleteError && (
<div className="mx-4 mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{deleteError}
</div>
)}
<div className="divide-y divide-slate-50">
{categories.map(category => (
<div key={category.id} className="flex items-center gap-3 px-4 py-3 group">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: category.color }} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-800">{category.name}</div>
<div className="text-xs text-slate-500">Ordning {category.sort_order}</div>
</div>
<div className="flex gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={() => setModal({ edit: category })} className="p-1 rounded hover:bg-slate-100 text-slate-400">
<Pencil size={13} />
</button>
<button onClick={() => onDelete(category.id)} className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-500">
<Trash2 size={13} />
</button>
</div>
</div>
))}
</div>
{modal === 'add' && (
<Modal title="Lägg till kategori" onClose={() => setModal(null)}>
<CategoryForm
suggestedSortOrder={suggestedSortOrder}
onSave={onAdd}
onClose={() => setModal(null)}
/>
</Modal>
)}
{modal?.edit && (
<Modal title="Redigera kategori" onClose={() => setModal(null)}>
<CategoryForm
initial={modal.edit}
suggestedSortOrder={modal.edit.sort_order}
onSave={data => onUpdate(modal.edit.id, data)}
onClose={() => setModal(null)}
/>
</Modal>
)}
</section>
);
}
+842
View File
@@ -0,0 +1,842 @@
import { useEffect, useMemo, useState } from 'react';
import { Landmark, Link2, Pencil, RefreshCw, ShieldCheck } from 'lucide-react';
import Modal from './Modal.jsx';
const COUNTRY_OPTIONS = [
{ value: 'SE', label: 'Sverige' },
{ value: 'FI', label: 'Finland' },
{ value: 'NO', label: 'Norge' },
{ value: 'DK', label: 'Danmark' },
];
const SYNC_INTERVAL_OPTIONS = [
{ value: 5, label: '5 min' },
{ value: 10, label: '10 min' },
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
{ value: 60, label: '1 timme' },
{ value: 180, label: '3 timmar' },
{ value: 360, label: '6 timmar' },
];
function formatCurrency(amount, currency = 'SEK') {
if (amount == null || Number.isNaN(Number(amount))) return 'Okänt saldo';
try {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: currency || 'SEK',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number(amount));
} catch {
return `${Number(amount).toFixed(2)} ${currency || 'SEK'}`;
}
}
function getPreferredBalance(account) {
const balances = Array.isArray(account?.balances) ? account.balances : [];
return balances.find(item => item.balance_type?.toLowerCase().includes('interimavailable'))
|| balances.find(item => item.balance_type?.toLowerCase().includes('expected'))
|| balances[0]
|| null;
}
function getTransactionSortDate(transaction) {
const normalized = String(transaction?.status || '').toLowerCase();
if (normalized === 'pending') {
return transaction.transaction_date
|| transaction.booking_date
|| transaction.value_date
|| '';
}
return transaction.booking_date
|| transaction.transaction_date
|| transaction.value_date
|| '';
}
function compareTransactions(a, b) {
const dateCompare = getTransactionSortDate(b).localeCompare(getTransactionSortDate(a));
if (dateCompare !== 0) return dateCompare;
const aPending = String(a.status || '').toLowerCase() === 'pending' ? 0 : 1;
const bPending = String(b.status || '').toLowerCase() === 'pending' ? 0 : 1;
if (aPending !== bPending) return aPending - bPending;
const syncedCompare = String(b.synced_at || '').localeCompare(String(a.synced_at || ''));
if (syncedCompare !== 0) return syncedCompare;
return String(b.uid || '').localeCompare(String(a.uid || ''));
}
function buildTransactionSignature(transaction) {
const description = [
transaction.creditor_name,
transaction.debtor_name,
transaction.note,
...(transaction.remittance_information || []),
].filter(Boolean).join('|');
return [
transaction.account_uid || '',
String(transaction.status || '').toLowerCase(),
transaction.booking_date || '',
transaction.transaction_date || '',
transaction.value_date || '',
transaction.amount ?? '',
transaction.currency || '',
transaction.credit_debit_indicator || '',
transaction.entry_reference || '',
transaction.reference_number || '',
description,
].join('::');
}
function transactionStatusLabel(status) {
const normalized = String(status || '').toLowerCase();
if (normalized === 'pending') return 'Väntande';
if (normalized === 'booked') return 'Bokförd';
return status || '';
}
function summarizeBankWarning(message) {
const text = String(message || '').trim();
if (!text) return 'Okänt banksvar';
if (/403/.test(text)) return 'Banken nekade anropet (403)';
if (/422|wrong_request_parameters/i.test(text)) return 'Banken underkände parametrarna (422)';
if (/400/.test(text)) return 'Banken svarade med ogiltig förfrågan (400)';
return text;
}
function AccountAliasCard({ account, busy, onSaveAlias }) {
const [alias, setAlias] = useState(account.alias || '');
useEffect(() => {
setAlias(account.alias || '');
}, [account.alias, account.uid]);
function handleSubmit(event) {
event.preventDefault();
onSaveAlias(account.uid, alias.trim());
}
const balance = getPreferredBalance(account);
const balanceAmount = balance?.balance_amount?.amount != null ? Number(balance.balance_amount.amount) : null;
const balanceCurrency = balance?.balance_amount?.currency || account.currency || 'SEK';
return (
<div className="rounded-xl border border-slate-200 px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-slate-800">{account.display_name || account.alias || account.name || 'Konto'}</div>
<div className="text-xs text-slate-500 mt-1">{account.iban || account.uid}</div>
</div>
<div className="text-right shrink-0">
<div className="text-sm font-semibold text-slate-800">
{formatCurrency(balanceAmount, balanceCurrency)}
</div>
<div className="text-xs text-slate-500">
{balance?.balance_type || 'Saldo'}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="mt-3 flex items-center gap-2">
<input
className="flex-1 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={alias}
onChange={event => setAlias(event.target.value)}
placeholder="Eget namn, t.ex. Lönekonto"
/>
<button
type="submit"
className="rounded-lg bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
disabled={busy}
>
Spara namn
</button>
</form>
</div>
);
}
function BankingForm({ initial, aspsps, loadingBanks, onRefreshBanks, onSave, onConnect, onDisconnect, onSync, onClose, busy }) {
const [enabled, setEnabled] = useState(initial?.enabled ?? false);
const [applicationId, setApplicationId] = useState(initial?.application_id ?? '');
const [privateKeyPem, setPrivateKeyPem] = useState('');
const [clearPrivateKey, setClearPrivateKey] = useState(false);
const [redirectUrl, setRedirectUrl] = useState(initial?.redirect_url ?? `${window.location.origin}/enablebanking/auth_callback`);
const [country, setCountry] = useState(initial?.country ?? 'SE');
const [psuType, setPsuType] = useState(initial?.psu_type ?? 'personal');
const [autoSyncEnabled, setAutoSyncEnabled] = useState(initial?.auto_sync_enabled ?? false);
const [syncIntervalMinutes, setSyncIntervalMinutes] = useState(initial?.sync_interval_minutes ?? 360);
const [importFromDate, setImportFromDate] = useState(initial?.import_from_date ?? '');
const [incrementalSyncDays, setIncrementalSyncDays] = useState(initial?.incremental_sync_days ?? 7);
const [institution, setInstitution] = useState(initial?.institution ?? '');
const [notes, setNotes] = useState(initial?.notes ?? '');
useEffect(() => {
if (enabled && applicationId && onRefreshBanks) {
onRefreshBanks(country);
}
}, [enabled, applicationId, country, onRefreshBanks]);
function submit(e) {
e.preventDefault();
onSave({
enabled,
application_id: applicationId.trim(),
private_key_pem: privateKeyPem.trim(),
clear_private_key: clearPrivateKey,
redirect_url: redirectUrl.trim(),
country,
psu_type: psuType,
auto_sync_enabled: autoSyncEnabled,
sync_interval_minutes: parseInt(syncIntervalMinutes) || 360,
import_from_date: importFromDate || null,
incremental_sync_days: parseInt(incrementalSyncDays) || 7,
notes: notes.trim(),
});
}
function handlePrivateKeyFile(event) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setPrivateKeyPem(String(reader.result || ''));
setClearPrivateKey(false);
};
reader.readAsText(file);
}
const hasPrivateKey = initial?.has_private_key || privateKeyPem.trim();
const canConnect = enabled && applicationId.trim() && hasPrivateKey && institution;
const syncDisabledReason = !initial?.enabled
? 'Aktivera integrationen först.'
: !initial?.application_id
? 'Applikations-ID saknas.'
: !initial?.has_private_key
? 'Private key är inte sparad ännu.'
: !initial?.session_id
? 'Ingen aktiv banksession finns ännu. Koppla banken först.'
: !(initial?.accounts?.length)
? 'Ingen kontoåtkomst hittades i sessionen ännu.'
: '';
return (
<form onSubmit={submit} className="space-y-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
className="rounded w-4 h-4 accent-blue-600"
/>
<span className="text-sm text-slate-700">Aktivera Enable Banking-integrationen</span>
</label>
<div className="rounded-xl border border-blue-100 bg-blue-50 px-3 py-3 text-sm text-blue-800">
Enable Banking använder ett applikations-ID (`kid`) och en privat RSA-nyckel för JWT-signering, inte en vanlig bearer API key.
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Applikations-ID / kid</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={applicationId}
onChange={e => setApplicationId(e.target.value)}
placeholder="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Private key (.pem)</label>
<textarea
className="w-full min-h-[120px] border border-slate-200 rounded-xl px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
value={privateKeyPem}
onChange={e => {
setPrivateKeyPem(e.target.value);
setClearPrivateKey(false);
}}
placeholder="-----BEGIN PRIVATE KEY-----"
/>
<div className="mt-2 flex flex-wrap items-center gap-3">
<input type="file" accept=".pem,.key,.txt" onChange={handlePrivateKeyFile} className="text-sm text-slate-600" />
{initial?.has_private_key && !privateKeyPem && (
<span className="text-xs text-green-700">En private key finns redan sparad serversidan.</span>
)}
{initial?.has_private_key && (
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
<input type="checkbox" checked={clearPrivateKey} onChange={e => setClearPrivateKey(e.target.checked)} />
Rensa sparad private key
</label>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Redirect URL</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={redirectUrl}
onChange={e => setRedirectUrl(e.target.value)}
placeholder={`${window.location.origin}/enablebanking/auth_callback`}
/>
<p className="mt-1 text-xs text-slate-500">Whitelista exakt denna URL i Enable Banking Control Panel.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Land</label>
<select
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={country}
onChange={e => setCountry(e.target.value)}
>
{COUNTRY_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">PSU-typ</label>
<select
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={psuType}
onChange={e => setPsuType(e.target.value)}
>
<option value="personal">Privat</option>
<option value="business">Företag</option>
</select>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-slate-700">Bank / institution</label>
<button
type="button"
onClick={() => onRefreshBanks(country)}
className="text-xs text-blue-600 hover:text-blue-700"
>
{loadingBanks ? 'Laddar banker...' : 'Hämta banker'}
</button>
</div>
<select
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={institution}
onChange={e => setInstitution(e.target.value)}
disabled={loadingBanks}
>
<option value="">Välj bank</option>
{aspsps.map(aspsp => (
<option key={`${aspsp.country}-${aspsp.name}`} value={aspsp.name}>
{aspsp.name}
</option>
))}
</select>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={autoSyncEnabled}
onChange={e => setAutoSyncEnabled(e.target.checked)}
className="rounded w-4 h-4 accent-blue-600"
/>
<span className="text-sm text-slate-700">Synka automatiskt i bakgrunden</span>
</label>
<div className="rounded-xl border border-sky-100 bg-sky-50 px-3 py-3 text-sm text-sky-900">
Enable Banking ger inte äkta push från banken. Enkelbudget kollar ungefär en gång per minut om det är dags för nästa sync, 5 minuter är närmast realtid just nu.
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Importera historik från datum</label>
<input
type="date"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={importFromDate}
onChange={e => setImportFromDate(e.target.value)}
/>
<p className="mt-1 text-xs text-slate-500">Används vid första fulla importen om ingen tidigare synk finns.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Hämta transaktioner automatiskt var</label>
<select
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={syncIntervalMinutes}
onChange={e => setSyncIntervalMinutes(e.target.value)}
>
{SYNC_INTERVAL_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<p className="mt-1 text-xs text-slate-500">
Gäller auto-sync i bakgrunden för nya banktransaktioner, saldouppdateringar och väntande poster. 5 minuter ger snabbast möjliga notiser i nuvarande upplägg.
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Hämta om senaste antal dagar vid varje synk</label>
<input
type="number"
min="1"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={incrementalSyncDays}
onChange={e => setIncrementalSyncDays(e.target.value)}
/>
<p className="mt-1 text-xs text-slate-500">Bra för sena bokningar och ändrade transaktioner.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Anteckning</label>
<textarea
className="w-full min-h-[88px] border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Valfri anteckning om integrationen"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button type="submit" className="py-2.5 rounded-xl bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50" disabled={busy}>
Spara inställningar
</button>
<button
type="button"
onClick={() => onConnect({
enabled,
application_id: applicationId.trim(),
private_key_pem: privateKeyPem.trim(),
clear_private_key: clearPrivateKey,
redirect_url: redirectUrl.trim(),
country,
psu_type: psuType,
auto_sync_enabled: autoSyncEnabled,
sync_interval_minutes: parseInt(syncIntervalMinutes) || 360,
import_from_date: importFromDate || null,
incremental_sync_days: parseInt(incrementalSyncDays) || 7,
notes: notes.trim(),
aspsp_name: institution,
})}
className="py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
disabled={!canConnect || busy}
>
Koppla banken
</button>
<button
type="button"
onClick={onSync}
className="py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
disabled={!initial?.session_id || busy}
>
Synka nu
</button>
<button
type="button"
onClick={onDisconnect}
className="py-2.5 rounded-xl border border-red-200 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
disabled={!initial?.session_id || busy}
>
Koppla från
</button>
</div>
{syncDisabledReason && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-800">
Synka nu är låst: {syncDisabledReason}
</div>
)}
<div className="pt-1">
<button type="button" onClick={onClose} className="w-full py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-600 hover:bg-slate-50">
Stäng
</button>
</div>
</form>
);
}
export default function EnableBankingSection({
integration,
aspsps,
loadingBanks,
busy,
error,
onRefreshBanks,
onSave,
onConnect,
onDisconnect,
onSync,
onUpdateAccount,
}) {
const [isOpen, setIsOpen] = useState(false);
const transactions = integration?.transactions ?? [];
const accounts = integration?.accounts ?? [];
const statusText = integration?.session_id ? 'Kopplad' : integration?.enabled ? 'Konfigurerad' : 'Inte kopplad';
const statusColor = integration?.session_id
? 'bg-green-100 text-green-700'
: integration?.enabled
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-700';
const latestTransactions = useMemo(() => {
const deduped = new Map();
for (const transaction of transactions) {
const signature = buildTransactionSignature(transaction);
const existing = deduped.get(signature);
if (!existing) {
deduped.set(signature, transaction);
continue;
}
const keepCurrent = compareTransactions(transaction, existing) < 0;
if (keepCurrent) {
deduped.set(signature, transaction);
}
}
return [...deduped.values()]
.sort(compareTransactions)
.slice(0, 5);
}, [transactions]);
const balanceWarnings = integration?.last_sync_summary?.balance_warnings || [];
const detailWarnings = integration?.last_sync_summary?.detail_warnings || [];
const transactionWarnings = integration?.last_sync_summary?.transaction_warnings || [];
const pendingCount = useMemo(
() => transactions.filter(transaction => String(transaction.status || '').toLowerCase() === 'pending').length,
[transactions]
);
const accountDiagnostics = useMemo(() => {
const byAccount = new Map();
for (const account of accounts) {
byAccount.set(account.uid, {
uid: account.uid,
name: account.display_name || account.alias || account.name || account.uid,
balanceWarning: null,
detailWarning: null,
transactionWarning: null,
});
}
for (const warning of balanceWarnings) {
const entry = byAccount.get(warning.account_uid) || {
uid: warning.account_uid,
name: warning.account_name || warning.account_uid,
balanceWarning: null,
detailWarning: null,
transactionWarning: null,
};
entry.balanceWarning = warning.message || null;
byAccount.set(entry.uid, entry);
}
for (const warning of detailWarnings) {
const entry = byAccount.get(warning.account_uid) || {
uid: warning.account_uid,
name: warning.account_name || warning.account_uid,
balanceWarning: null,
detailWarning: null,
transactionWarning: null,
};
entry.detailWarning = warning.message || null;
byAccount.set(entry.uid, entry);
}
for (const warning of transactionWarnings) {
const entry = byAccount.get(warning.account_uid) || {
uid: warning.account_uid,
name: warning.account_name || warning.account_uid,
balanceWarning: null,
detailWarning: null,
transactionWarning: null,
};
entry.transactionWarning = warning.message || null;
byAccount.set(entry.uid, entry);
}
return [...byAccount.values()].filter(item => item.balanceWarning || item.detailWarning || item.transactionWarning);
}, [accounts, balanceWarnings, detailWarnings, transactionWarnings]);
const diagnostics = [
{ label: 'Integration aktiverad', ok: Boolean(integration?.enabled), value: integration?.enabled ? 'Ja' : 'Nej' },
{ label: 'Applikations-ID sparat', ok: Boolean(integration?.application_id), value: integration?.application_id ? 'Ja' : 'Nej' },
{ label: 'Private key sparad', ok: Boolean(integration?.has_private_key), value: integration?.has_private_key ? 'Ja' : 'Nej' },
{ label: 'Bank vald', ok: Boolean(integration?.institution), value: integration?.institution || 'Ingen' },
{ label: 'Aktiv session', ok: Boolean(integration?.session_id), value: integration?.session_id ? 'Ja' : 'Nej' },
{ label: 'Konton hittade', ok: (accounts.length > 0), value: String(accounts.length) },
{ label: 'Callback nådde appen', ok: Boolean(integration?.last_callback_at), value: integration?.last_callback_at ? 'Ja' : 'Nej' },
{ label: 'Code i callback', ok: Boolean(integration?.last_callback_code_present), value: integration?.last_callback_code_present ? 'Ja' : 'Nej' },
];
return (
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden mb-3">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div className="flex items-center gap-2">
<Landmark size={16} className="text-blue-600" />
<span className="font-semibold text-slate-700 text-sm uppercase tracking-wide">Enable Banking</span>
</div>
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-1 text-blue-600 text-xs font-medium hover:text-blue-700"
>
<Pencil size={14} /> Konfigurera
</button>
</div>
<div className="px-4 py-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${statusColor}`}>
{statusText}
</span>
{integration?.auto_sync_enabled && (
<span className="text-xs px-2.5 py-1 rounded-full font-medium bg-violet-100 text-violet-700">
Auto-sync
</span>
)}
{integration?.auto_sync_enabled && Number(integration?.sync_interval_minutes || 360) <= 10 && (
<span className="text-xs px-2.5 py-1 rounded-full font-medium bg-sky-100 text-sky-700">
Nära realtid
</span>
)}
{integration?.has_private_key && (
<span className="text-xs px-2.5 py-1 rounded-full font-medium bg-emerald-100 text-emerald-700">
Nyckel sparad
</span>
)}
</div>
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
{error}
</div>
)}
{integration?.last_error && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-800">
Senaste fel: {integration.last_error}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-slate-500 text-xs mb-1">Bank</div>
<div className="text-slate-700 font-medium">{integration?.institution || 'Ej vald ännu'}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-slate-500 text-xs mb-1">Senaste synk</div>
<div className="text-slate-700 font-medium flex items-center gap-1.5">
<RefreshCw size={13} className="text-slate-400" />
{integration?.last_sync_at ? new Date(integration.last_sync_at).toLocaleString('sv-SE') : 'Ingen synk ännu'}
</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-slate-500 text-xs mb-1">Applikations-ID</div>
<div className="text-slate-700 font-medium break-all">{integration?.application_id || 'Saknas'}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-slate-500 text-xs mb-1">Session giltig till</div>
<div className="text-slate-700 font-medium">{integration?.session_expires_at ? new Date(integration.session_expires_at).toLocaleString('sv-SE') : 'Ingen aktiv session'}</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="rounded-xl border border-slate-200 px-3 py-3">
<div className="text-xs text-slate-500 mb-1">Konton</div>
<div className="text-lg font-semibold text-slate-800">{accounts.length}</div>
</div>
<div className="rounded-xl border border-slate-200 px-3 py-3">
<div className="text-xs text-slate-500 mb-1">Transaktioner</div>
<div className="text-lg font-semibold text-slate-800">{transactions.length}</div>
{pendingCount > 0 && (
<div className="text-xs text-amber-700 mt-1">{pendingCount} väntande</div>
)}
</div>
<div className="rounded-xl border border-slate-200 px-3 py-3">
<div className="text-xs text-slate-500 mb-1">Auto-sync för transaktioner</div>
<div className="text-lg font-semibold text-slate-800">{integration?.sync_interval_minutes || 360} min</div>
{integration?.auto_sync_enabled && Number(integration?.sync_interval_minutes || 360) <= 10 && (
<div className="text-xs text-sky-700 mt-1">Snabbaste läget för notiser</div>
)}
</div>
<div className="rounded-xl border border-slate-200 px-3 py-3">
<div className="text-xs text-slate-500 mb-1">Historik från</div>
<div className="text-lg font-semibold text-slate-800">{integration?.import_from_date || 'Standard'}</div>
</div>
</div>
<div className="rounded-xl border border-slate-200 overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold uppercase tracking-wide text-slate-600">
Diagnostik
</div>
<div className="divide-y divide-slate-100">
{diagnostics.map(item => (
<div key={item.label} className="px-3 py-2.5 flex items-center justify-between gap-3 text-sm">
<span className="text-slate-700">{item.label}</span>
<span className={item.ok ? 'text-emerald-700 font-medium' : 'text-amber-700 font-medium'}>
{item.value}
</span>
</div>
))}
{integration?.pending_state && (
<div className="px-3 py-2.5 flex items-center justify-between gap-3 text-sm">
<span className="text-slate-700">Väntar callback</span>
<span className="text-blue-700 font-medium">Ja</span>
</div>
)}
</div>
</div>
{(integration?.last_callback_at || integration?.last_callback_url || integration?.last_callback_exchange_error) && (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-700 space-y-2">
<div className="font-medium text-slate-800">Senaste callback-diagnostik</div>
{integration?.last_callback_at && (
<div>
<span className="text-slate-500">Tid:</span>{' '}
{new Date(integration.last_callback_at).toLocaleString('sv-SE')}
</div>
)}
{integration?.last_callback_url && (
<div className="break-all">
<span className="text-slate-500">URL:</span>{' '}
{integration.last_callback_url}
</div>
)}
{integration?.last_callback_state && (
<div className="break-all">
<span className="text-slate-500">State i callback:</span>{' '}
{integration.last_callback_state}
</div>
)}
{integration?.pending_state && (
<div className="break-all">
<span className="text-slate-500">Väntad state:</span>{' '}
{integration.pending_state}
</div>
)}
{integration?.last_callback_exchange_error && (
<div className="text-amber-700">
<span className="text-slate-500">Exchange-fel:</span>{' '}
{integration.last_callback_exchange_error}
</div>
)}
</div>
)}
{integration?.last_sync_summary && (
<div className="rounded-xl bg-emerald-50 border border-emerald-100 px-3 py-3 text-sm text-emerald-800">
Senaste synken importerade {integration.last_sync_summary.imported_transactions} transaktioner mellan {integration.last_sync_summary.date_from} och {integration.last_sync_summary.date_to}.
</div>
)}
{accountDiagnostics.length > 0 && (
<div className="rounded-xl border border-slate-200 overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold uppercase tracking-wide text-slate-600">
Bankdiagnostik
</div>
<div className="space-y-3 p-3 bg-slate-50/70">
{accountDiagnostics.map(item => (
<div key={item.uid} className="rounded-2xl border border-slate-200 bg-white px-3 py-3">
<div className="mb-2 text-sm font-semibold text-slate-800">{item.name}</div>
<div className="grid gap-2 sm:grid-cols-3">
<div className={`rounded-xl px-3 py-2 text-sm ${item.balanceWarning ? 'bg-amber-50 text-amber-800' : 'bg-emerald-50 text-emerald-700'}`}>
<div className="text-xs font-semibold uppercase tracking-wide">Balances</div>
<div className="mt-1 leading-5">{item.balanceWarning ? summarizeBankWarning(item.balanceWarning) : 'OK'}</div>
</div>
<div className={`rounded-xl px-3 py-2 text-sm ${item.detailWarning ? 'bg-amber-50 text-amber-800' : 'bg-emerald-50 text-emerald-700'}`}>
<div className="text-xs font-semibold uppercase tracking-wide">Details</div>
<div className="mt-1 leading-5">{item.detailWarning ? summarizeBankWarning(item.detailWarning) : 'OK'}</div>
</div>
<div className={`rounded-xl px-3 py-2 text-sm ${item.transactionWarning ? 'bg-amber-50 text-amber-800' : 'bg-emerald-50 text-emerald-700'}`}>
<div className="text-xs font-semibold uppercase tracking-wide">Transactions</div>
<div className="mt-1 leading-5">{item.transactionWarning ? summarizeBankWarning(item.transactionWarning) : 'OK'}</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{accounts.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-2">
<ShieldCheck size={14} className="text-emerald-600" />
Synkade konton
</div>
<div className="space-y-2">
{accounts.map(account => (
<AccountAliasCard
key={account.uid}
account={account}
busy={busy}
onSaveAlias={onUpdateAccount}
/>
))}
</div>
</div>
)}
{latestTransactions.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-2">
<Link2 size={14} className="text-violet-600" />
Senaste transaktioner
</div>
<div className="space-y-2">
{latestTransactions.map(transaction => (
<div key={transaction.uid} className="rounded-xl border border-slate-200 px-3 py-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm text-slate-800 truncate">
{transaction.creditor_name || transaction.debtor_name || transaction.note || transaction.remittance_information?.[0] || 'Transaktion'}
</div>
<div className="text-xs text-slate-500 mt-1">
{transaction.booking_date || transaction.transaction_date || 'Okänt datum'}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-sm font-semibold text-slate-800">
{transaction.amount != null ? `${transaction.amount} ${transaction.currency || ''}` : ''}
</div>
<div className="text-xs text-slate-500">
{transactionStatusLabel(transaction.status) || transaction.credit_debit_indicator || ''}
</div>
</div>
</div>
))}
</div>
</div>
)}
{!integration?.session_id && (
<p className="text-sm text-slate-600">
För att detta att fungera behöver du registrera en app i Enable Banking Control Panel, spara appens private key och sedan koppla banken härifrån.
</p>
)}
</div>
{isOpen && (
<Modal title="Enable Banking" onClose={() => setIsOpen(false)}>
<BankingForm
initial={integration}
aspsps={aspsps}
loadingBanks={loadingBanks}
onRefreshBanks={onRefreshBanks}
onSave={onSave}
onConnect={onConnect}
onDisconnect={onDisconnect}
onSync={onSync}
onClose={() => setIsOpen(false)}
busy={busy}
/>
</Modal>
)}
</section>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { ArrowUpDown, Bot, CreditCard, LogOut, Menu, Settings, Wallet, X } from 'lucide-react';
export default function Header({ user, onLogout }) {
const location = useLocation();
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
setMenuOpen(false);
}, [location.pathname, location.search]);
const linkClass = ({ isActive }) =>
`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'text-slate-600 hover:bg-slate-100'
}`;
return (
<header className="bg-white border-b border-slate-200 sticky top-0 z-40 ios-safe-top">
<div className="max-w-6xl mx-auto px-4 py-2">
<div className="flex items-center justify-between gap-3 sm:h-14">
<div className="flex items-center gap-3">
<span className="font-bold text-slate-800 text-lg tracking-tight">
Enkelbudget
</span>
<button
type="button"
onClick={() => setMenuOpen(current => !current)}
className="inline-flex sm:hidden items-center justify-center rounded-xl border border-slate-200 p-2 text-slate-600 hover:bg-slate-50"
aria-label={menuOpen ? 'Stäng meny' : 'Öppna meny'}
aria-expanded={menuOpen}
>
{menuOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
{user && (
<div className="sm:hidden flex items-center gap-2">
<span className="text-xs text-slate-500 truncate max-w-[140px]">
{user.name || user.email || user.sub}
</span>
<button
type="button"
onClick={onLogout}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
<LogOut size={13} />
Logga ut
</button>
</div>
)}
</div>
<div className="hidden sm:flex items-center justify-between gap-3">
<nav className="flex gap-1 flex-wrap justify-end">
<NavLink to={`/budget/${new Date().getFullYear()}/${new Date().getMonth() + 1}`} className={linkClass} end={false}>
<Wallet size={15} />
Budget
</NavLink>
<NavLink to="/transaktioner" className={linkClass}>
<ArrowUpDown size={15} />
Transaktioner
</NavLink>
<NavLink to="/ai" className={linkClass}>
<Bot size={15} />
AI
</NavLink>
<NavLink to="/lan" className={linkClass}>
<CreditCard size={15} />
Lån
</NavLink>
<NavLink to="/installningar" className={linkClass}>
<Settings size={15} />
Inställningar
</NavLink>
</nav>
{user && (
<div className="hidden sm:flex items-center gap-2">
<span className="text-xs text-slate-500 max-w-[180px] truncate">
{user.name || user.email || user.sub}
</span>
<button
type="button"
onClick={onLogout}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
<LogOut size={13} />
Logga ut
</button>
</div>
)}
</div>
{menuOpen && (
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50 p-2 sm:hidden">
<nav className="grid gap-1">
<NavLink to={`/budget/${new Date().getFullYear()}/${new Date().getMonth() + 1}`} className={linkClass} end={false}>
<Wallet size={15} />
Budget
</NavLink>
<NavLink to="/transaktioner" className={linkClass}>
<ArrowUpDown size={15} />
Transaktioner
</NavLink>
<NavLink to="/ai" className={linkClass}>
<Bot size={15} />
AI
</NavLink>
<NavLink to="/lan" className={linkClass}>
<CreditCard size={15} />
Lån
</NavLink>
<NavLink to="/installningar" className={linkClass}>
<Settings size={15} />
Inställningar
</NavLink>
</nav>
</div>
)}
</div>
</header>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useState } from 'react';
import { Plus, Pencil, Trash2, TrendingUp } from 'lucide-react';
import { fmt, parseAmount } from '../utils.js';
import Modal from './Modal.jsx';
const TYPE_LABELS = {
salary: { label: 'Lön', color: 'bg-blue-100 text-blue-700' },
benefit: { label: 'Förmån', color: 'bg-purple-100 text-purple-700' },
optional: { label: 'Valfri', color: 'bg-amber-100 text-amber-700' },
other: { label: 'Övrigt', color: 'bg-slate-100 text-slate-600' },
};
function IncomeForm({ initial, onSave, onClose }) {
const [name, setName] = useState(initial?.name ?? '');
const [amount, setAmount] = useState(initial?.amount ?? '');
const [type, setType] = useState(initial?.type ?? 'salary');
const [notes, setNotes] = useState(initial?.notes ?? '');
function submit(e) {
e.preventDefault();
if (!name.trim() || isNaN(parseAmount(amount))) return;
onSave({ name: name.trim(), amount: parseAmount(amount), type, notes: notes.trim() || null });
onClose();
}
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Namn</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={name} onChange={e => setName(e.target.value)} placeholder="t.ex. Lön" required autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Belopp (kr)</label>
<input
type="text" inputMode="decimal"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={amount} onChange={e => setAmount(e.target.value)} placeholder="0" required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={type} onChange={e => setType(e.target.value)}
>
<option value="salary">Lön</option>
<option value="benefit">Förmån (t.ex. ePassi)</option>
<option value="optional">Valfri inkomst</option>
<option value="other">Övrigt</option>
</select>
</div>
{type === 'benefit' && (
<p className="text-xs text-purple-600 bg-purple-50 rounded-lg px-3 py-2">
Förmåner visas separat och påverkar inte den ordinarie balansen.
</p>
)}
{type === 'optional' && (
<p className="text-xs text-amber-600 bg-amber-50 rounded-lg px-3 py-2">
Valfri inkomst visas separat i sammanfattningen.
</p>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Anteckning (valfri)</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={notes} onChange={e => setNotes(e.target.value)} placeholder="Valfri notering"
/>
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-600 hover:bg-slate-50">
Avbryt
</button>
<button type="submit" className="flex-1 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
Spara
</button>
</div>
</form>
);
}
export default function IncomeSection({ income, monthId, onAdd, onUpdate, onDelete }) {
const [modal, setModal] = useState(null); // null | 'add' | {edit: item}
const mandatory = income.filter(i => i.type !== 'optional' && i.type !== 'benefit');
const optional = income.filter(i => i.type === 'optional');
const benefits = income.filter(i => i.type === 'benefit');
const totalMandatory = mandatory.reduce((s, i) => s + i.amount, 0);
return (
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden mb-3">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div className="flex items-center gap-2">
<TrendingUp size={16} className="text-green-500" />
<span className="font-semibold text-slate-700 text-sm uppercase tracking-wide">Inkomster</span>
</div>
<button
onClick={() => setModal('add')}
className="flex items-center gap-1 text-blue-600 text-xs font-medium hover:text-blue-700"
>
<Plus size={14} /> Lägg till
</button>
</div>
<div className="divide-y divide-slate-50">
{income.map(item => {
const tInfo = TYPE_LABELS[item.type] ?? TYPE_LABELS.other;
return (
<div key={item.id} className="flex items-center gap-3 px-4 py-2.5 group">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${tInfo.color}`}>{tInfo.label}</span>
<span className="flex-1 text-sm text-slate-700 truncate">{item.name}</span>
{item.notes && <span className="text-xs text-slate-400 truncate max-w-[100px] hidden sm:block">{item.notes}</span>}
<span className="text-sm font-semibold text-slate-800 shrink-0">{fmt(item.amount)}</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={() => setModal({ edit: item })} className="p-1 rounded hover:bg-slate-100 text-slate-400">
<Pencil size={13} />
</button>
<button onClick={() => onDelete(item.id)} className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-500">
<Trash2 size={13} />
</button>
</div>
</div>
);
})}
{income.length === 0 && (
<p className="px-4 py-3 text-sm text-slate-400 italic">Inga inkomster tillagda</p>
)}
</div>
<div className="px-4 py-2.5 bg-slate-50 border-t border-slate-100 flex justify-between items-center">
<span className="text-xs text-slate-500">
{optional.length > 0 && `+ ${fmt(optional.reduce((s, i) => s + i.amount, 0))} valfri`}
{benefits.length > 0 && ` + ${fmt(benefits.reduce((s, i) => s + i.amount, 0))} förmån`}
</span>
<div className="text-right">
<span className="text-xs text-slate-500 mr-2">Obligatorisk inkomst</span>
<span className="font-bold text-green-600">{fmt(totalMandatory)}</span>
</div>
</div>
{modal === 'add' && (
<Modal title="Lägg till inkomst" onClose={() => setModal(null)}>
<IncomeForm
onSave={(data) => onAdd(monthId, data)}
onClose={() => setModal(null)}
/>
</Modal>
)}
{modal?.edit && (
<Modal title="Redigera inkomst" onClose={() => setModal(null)}>
<IncomeForm
initial={modal.edit}
onSave={(data) => onUpdate(modal.edit.id, data)}
onClose={() => setModal(null)}
/>
</Modal>
)}
</section>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useState } from 'react';
import { Pencil, Trash2, ChevronDown, ChevronUp, Plus, CreditCard } from 'lucide-react';
import { fmt, estimatedPayoff, today, parseAmount } from '../utils.js';
import Modal from './Modal.jsx';
function PaymentForm({ loan, onSave, onClose }) {
const [amount, setAmount] = useState(loan.monthly_payment ?? '');
const [date, setDate] = useState(today());
const [notes, setNotes] = useState('');
function submit(e) {
e.preventDefault();
if (isNaN(parseAmount(amount))) return;
onSave({ amount: parseAmount(amount), payment_date: date, notes: notes.trim() || null });
onClose();
}
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Belopp (kr)</label>
<input
type="text" inputMode="decimal"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={amount} onChange={e => setAmount(e.target.value)} required autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datum</label>
<input
type="date"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={date} onChange={e => setDate(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Anteckning (valfri)</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={notes} onChange={e => setNotes(e.target.value)} placeholder="Valfri notering"
/>
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-600 hover:bg-slate-50">
Avbryt
</button>
<button type="submit" className="flex-1 py-2.5 rounded-xl bg-green-600 text-white text-sm font-medium hover:bg-green-700">
Registrera
</button>
</div>
</form>
);
}
export default function LoanCard({ loan, payments = [], onPayment, onEdit, onDelete }) {
const [expanded, setExpanded] = useState(false);
const [payModal, setPayModal] = useState(false);
const progress = loan.original_amount > 0
? Math.min(100, Math.round(((loan.original_amount - loan.current_balance) / loan.original_amount) * 100))
: 0;
const payoff = estimatedPayoff(loan.current_balance, loan.monthly_payment, loan.interest_rate);
const isPaidOff = loan.current_balance <= 0;
return (
<div className={`bg-white rounded-2xl shadow-sm border overflow-hidden mb-3 ${!loan.is_active ? 'opacity-60' : 'border-slate-200'}`}>
<div className="px-4 py-4">
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex items-center gap-2">
<CreditCard size={16} className="text-blue-500 shrink-0 mt-0.5" />
<div className="min-w-0">
<h3 className="truncate font-semibold text-slate-800 text-sm">{loan.name}</h3>
{loan.notes && <p className="text-xs text-slate-400 mt-0.5">{loan.notes}</p>}
</div>
</div>
<div className="flex flex-wrap gap-1 shrink-0">
{loan.is_active && (
<button
onClick={() => setPayModal(true)}
className="flex items-center gap-1 text-xs bg-green-50 text-green-600 px-2.5 py-1 rounded-lg font-medium hover:bg-green-100"
>
<Plus size={12} /> Betala
</button>
)}
<button onClick={onEdit} className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400">
<Pencil size={14} />
</button>
<button onClick={onDelete} className="p-1.5 rounded-lg hover:bg-red-50 text-slate-400 hover:text-red-500">
<Trash2 size={14} />
</button>
</div>
</div>
<div className="mb-2.5">
<div className="flex justify-between text-xs text-slate-500 mb-1.5">
<span>{isPaidOff ? 'Betalt!' : `Saldo: ${fmt(loan.current_balance)}`}</span>
<span className="font-medium text-slate-700">{progress}% avbetalt</span>
</div>
<div className="h-2.5 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${progress}%`,
backgroundColor: isPaidOff ? '#16A34A' : progress > 75 ? '#22C55E' : progress > 40 ? '#3B82F6' : '#6366F1',
}}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-2 text-center sm:grid-cols-3">
<div className="bg-slate-50 rounded-xl px-2 py-1.5">
<p className="text-xs text-slate-400">Ursprung</p>
<p className="text-xs font-semibold text-slate-700 mt-0.5">{fmt(loan.original_amount)}</p>
</div>
<div className="bg-slate-50 rounded-xl px-2 py-1.5">
<p className="text-xs text-slate-400">Månadsbet.</p>
<p className="text-xs font-semibold text-slate-700 mt-0.5">{fmt(loan.monthly_payment)}</p>
</div>
<div className="bg-slate-50 rounded-xl px-2 py-1.5">
<p className="text-xs text-slate-400">Klar ca</p>
<p className="text-xs font-semibold text-slate-700 mt-0.5">{payoff ?? ''}</p>
</div>
</div>
{loan.interest_rate > 0 && (
<p className="text-xs text-slate-400 mt-2">Ränta: {loan.interest_rate}%</p>
)}
</div>
{payments.length > 0 && (
<>
<button
onClick={() => setExpanded(e => !e)}
className="w-full flex items-center justify-between px-4 py-2 border-t border-slate-100 text-xs text-slate-500 hover:bg-slate-50 transition-colors"
>
<span>Betalningshistorik ({payments.length})</span>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{expanded && (
<div className="border-t border-slate-100 divide-y divide-slate-50 max-h-48 overflow-y-auto">
{payments.map(p => (
<div key={p.id} className="flex items-center justify-between px-4 py-2">
<span className="text-xs text-slate-500">{p.payment_date}</span>
{p.notes && <span className="text-xs text-slate-400 flex-1 mx-2 truncate">{p.notes}</span>}
<span className="text-xs font-semibold text-green-600">{fmt(p.amount)}</span>
</div>
))}
</div>
)}
</>
)}
{payModal && (
<Modal title={`Registrera betalning ${loan.name}`} onClose={() => setPayModal(false)}>
<PaymentForm
loan={loan}
onSave={(data) => { onPayment(loan.id, data); setPayModal(false); }}
onClose={() => setPayModal(false)}
/>
</Modal>
)}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { X } from 'lucide-react';
import { useEffect } from 'react';
export default function Modal({ title, onClose, children, maxWidthClass = 'sm:max-w-md' }) {
useEffect(() => {
const handler = (e) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className={`relative bg-white w-full ${maxWidthClass} sm:rounded-2xl rounded-t-2xl shadow-xl max-h-[90vh] overflow-y-auto`}>
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b border-slate-100">
<h2 className="font-semibold text-slate-800 text-base">{title}</h2>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-slate-100 text-slate-500">
<X size={18} />
</button>
</div>
<div className="px-5 py-4">{children}</div>
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { useNavigate } from 'react-router-dom';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { monthLabel } from '../utils.js';
export default function MonthNav({ year, month }) {
const navigate = useNavigate();
function go(delta) {
let m = month + delta;
let y = year;
if (m < 1) { m = 12; y--; }
if (m > 12) { m = 1; y++; }
navigate(`/budget/${y}/${m}`);
}
const now = new Date();
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth() + 1;
return (
<div className="flex items-center justify-between py-4">
<button
onClick={() => go(-1)}
className="p-2 rounded-xl hover:bg-slate-200 text-slate-500 transition-colors"
>
<ChevronLeft size={20} />
</button>
<div className="text-center">
<h1 className="font-bold text-xl text-slate-800 capitalize">
{monthLabel(year, month)}
</h1>
{isCurrentMonth && (
<span className="text-xs text-blue-600 font-medium">Nuvarande månad</span>
)}
</div>
<button
onClick={() => go(1)}
className="p-2 rounded-xl hover:bg-slate-200 text-slate-500 transition-colors"
>
<ChevronRight size={20} />
</button>
</div>
);
}
+303
View File
@@ -0,0 +1,303 @@
import { useState } from 'react';
import { KeyRound, Pencil, ShieldCheck } from 'lucide-react';
import Modal from './Modal.jsx';
function OidcForm({ initial, onSave, onClose, busy }) {
const [enabled, setEnabled] = useState(initial?.enabled ?? false);
const [issuer, setIssuer] = useState(initial?.issuer ?? '');
const [discoveryUrl, setDiscoveryUrl] = useState(initial?.discovery_url ?? '');
const [clientId, setClientId] = useState(initial?.client_id ?? '');
const [clientSecret, setClientSecret] = useState('');
const [clearClientSecret, setClearClientSecret] = useState(false);
const [redirectUri, setRedirectUri] = useState(initial?.redirect_uri ?? `${window.location.origin}/auth/callback`);
const [scope, setScope] = useState(initial?.scope ?? 'openid profile email groups');
const [sessionSecret, setSessionSecret] = useState('');
const [clearSessionSecret, setClearSessionSecret] = useState(false);
const [sessionTtlHours, setSessionTtlHours] = useState(initial?.session_ttl_hours ?? 12);
const [allowedGroups, setAllowedGroups] = useState(initial?.allowed_groups ?? '');
const [allowedEmails, setAllowedEmails] = useState(initial?.allowed_emails ?? '');
const [allowedDomains, setAllowedDomains] = useState(initial?.allowed_domains ?? '');
const redirectUriLooksValid = /^https:\/\/.+/i.test(redirectUri.trim());
function submit(event) {
event.preventDefault();
if (!redirectUriLooksValid) return;
onSave({
enabled,
issuer: issuer.trim(),
discovery_url: discoveryUrl.trim(),
client_id: clientId.trim(),
client_secret: clientSecret.trim(),
clear_client_secret: clearClientSecret,
redirect_uri: redirectUri.trim(),
scope: scope.trim(),
session_secret: sessionSecret.trim(),
clear_session_secret: clearSessionSecret,
session_ttl_hours: parseInt(sessionTtlHours, 10) || 12,
allowed_groups: allowedGroups.trim(),
allowed_emails: allowedEmails.trim(),
allowed_domains: allowedDomains.trim(),
});
}
return (
<form onSubmit={submit} className="space-y-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={event => setEnabled(event.target.checked)}
className="rounded w-4 h-4 accent-blue-600"
/>
<span className="text-sm text-slate-700">Aktivera Pocket ID / OIDC-inloggning</span>
</label>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Issuer</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={issuer}
onChange={event => setIssuer(event.target.value)}
placeholder="https://pocketid.example.com"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Discovery URL</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={discoveryUrl}
onChange={event => setDiscoveryUrl(event.target.value)}
placeholder="Lämna tomt för /.well-known/openid-configuration"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Client ID</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={clientId}
onChange={event => setClientId(event.target.value)}
placeholder="UUID från Pocket ID"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Client secret</label>
<input
type="password"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={clientSecret}
onChange={event => {
setClientSecret(event.target.value);
setClearClientSecret(false);
}}
placeholder={initial?.has_client_secret ? 'Sparad på serversidan' : 'Klistra in client secret'}
/>
{initial?.has_client_secret && (
<label className="mt-2 flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
<input type="checkbox" checked={clearClientSecret} onChange={event => setClearClientSecret(event.target.checked)} />
Rensa sparad client secret
</label>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Redirect URI</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={redirectUri}
onChange={event => setRedirectUri(event.target.value)}
placeholder={`${window.location.origin}/auth/callback`}
/>
<p className="mt-1 text-xs text-slate-500">Registrera exakt denna callback-URL i Pocket ID-klienten.</p>
{!redirectUriLooksValid && (
<p className="mt-1 text-xs text-red-600">Redirect URI måste börja med `https://`.</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Scope</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={scope}
onChange={event => setScope(event.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Sessiontid (timmar)</label>
<input
type="number"
min="1"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={sessionTtlHours}
onChange={event => setSessionTtlHours(event.target.value)}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Session secret</label>
<input
type="password"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={sessionSecret}
onChange={event => {
setSessionSecret(event.target.value);
setClearSessionSecret(false);
}}
placeholder={initial?.has_session_secret ? 'Sparad på serversidan' : 'Lång slumpad hemlighet'}
/>
{initial?.has_session_secret && (
<label className="mt-2 flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
<input type="checkbox" checked={clearSessionSecret} onChange={event => setClearSessionSecret(event.target.checked)} />
Rensa sparad session secret
</label>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Tillåtna grupper</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={allowedGroups}
onChange={event => setAllowedGroups(event.target.value)}
placeholder="admin, family"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Tillåtna e-postadresser</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={allowedEmails}
onChange={event => setAllowedEmails(event.target.value)}
placeholder="namn@example.com, annan@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Tillåtna domäner</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={allowedDomains}
onChange={event => setAllowedDomains(event.target.value)}
placeholder="bitli.se"
/>
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-600 hover:bg-slate-50">
Avbryt
</button>
<button type="submit" disabled={busy || !redirectUriLooksValid} className="flex-1 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
Spara
</button>
</div>
</form>
);
}
export default function OidcSettingsSection({ settings, error, busy, onSave }) {
const [isOpen, setIsOpen] = useState(false);
const diagnostics = [
{ label: 'Aktiverad', value: settings?.enabled ? 'Ja' : 'Nej', ok: Boolean(settings?.enabled) },
{ label: 'Issuer sparad', value: settings?.issuer ? 'Ja' : 'Nej', ok: Boolean(settings?.issuer) },
{ label: 'Client ID sparat', value: settings?.client_id ? 'Ja' : 'Nej', ok: Boolean(settings?.client_id) },
{ label: 'Client secret sparad', value: settings?.has_client_secret ? 'Ja' : 'Nej', ok: Boolean(settings?.has_client_secret) },
{ label: 'Session secret sparad', value: settings?.has_session_secret ? 'Ja' : 'Nej', ok: Boolean(settings?.has_session_secret) },
{ label: 'Konfiguration komplett', value: settings?.is_valid ? 'Ja' : 'Nej', ok: Boolean(settings?.is_valid) },
];
return (
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden mb-3">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div className="flex items-center gap-2">
<KeyRound size={16} className="text-sky-600" />
<span className="font-semibold text-slate-700 text-sm uppercase tracking-wide">Pocket ID / OIDC</span>
</div>
<button onClick={() => setIsOpen(true)} className="flex items-center gap-1 text-blue-600 text-xs font-medium hover:text-blue-700">
<Pencil size={14} /> Konfigurera
</button>
</div>
<div className="px-4 py-3 border-b border-slate-100 bg-sky-50/70 text-sm text-slate-600">
Här styr du appens inloggning. När Pocket ID är aktiverat skyddas hela Enkelbudget bakom OIDC/SSO.
</div>
<div className="px-4 py-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${settings?.enabled ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-700'}`}>
{settings?.enabled ? 'Aktiverad' : 'Avstängd'}
</span>
{settings?.is_valid && (
<span className="text-xs px-2.5 py-1 rounded-full font-medium bg-blue-100 text-blue-700">
Redo för login
</span>
)}
</div>
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
{error}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-slate-500 text-xs mb-1">Issuer</div>
<div className="text-slate-700 font-medium break-all">{settings?.issuer || 'Inte satt ännu'}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-slate-500 text-xs mb-1">Redirect URI</div>
<div className="text-slate-700 font-medium break-all">{settings?.redirect_uri || `${window.location.origin}/auth/callback`}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-slate-500 text-xs mb-1">Scope</div>
<div className="text-slate-700 font-medium">{settings?.scope || 'openid profile email groups'}</div>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-3">
<div className="text-slate-500 text-xs mb-1">Sessiontid</div>
<div className="text-slate-700 font-medium">{settings?.session_ttl_hours || 12} timmar</div>
</div>
</div>
<div className="rounded-xl border border-slate-200 overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold uppercase tracking-wide text-slate-600">
Diagnostik
</div>
<div className="divide-y divide-slate-100">
{diagnostics.map(item => (
<div key={item.label} className="px-3 py-2.5 flex items-center justify-between gap-3 text-sm">
<span className="text-slate-700">{item.label}</span>
<span className={item.ok ? 'text-emerald-700 font-medium' : 'text-amber-700 font-medium'}>
{item.value}
</span>
</div>
))}
</div>
</div>
{(settings?.allowed_groups || settings?.allowed_emails || settings?.allowed_domains) && (
<div className="rounded-xl border border-sky-100 bg-sky-50 px-3 py-3 text-sm text-sky-900">
<div className="flex items-center gap-2 font-medium mb-2">
<ShieldCheck size={14} className="text-sky-700" />
Åtkomstregler
</div>
{settings?.allowed_groups && <div>Grupper: {settings.allowed_groups}</div>}
{settings?.allowed_emails && <div>E-post: {settings.allowed_emails}</div>}
{settings?.allowed_domains && <div>Domäner: {settings.allowed_domains}</div>}
</div>
)}
</div>
{isOpen && (
<Modal title="Pocket ID / OIDC" onClose={() => setIsOpen(false)}>
<OidcForm initial={settings} onSave={onSave} onClose={() => setIsOpen(false)} busy={busy} />
</Modal>
)}
</section>
);
}
+303
View File
@@ -0,0 +1,303 @@
import { useEffect, useMemo, useState } from 'react';
import { BadgePlus, CalendarDays, CheckCircle2, Pencil, Plus, Repeat, Trash2 } from 'lucide-react';
import { api } from '../api.js';
import { fmt, parseAmount } from '../utils.js';
import Modal from './Modal.jsx';
const CURRENCY_OPTIONS = ['SEK', 'EUR', 'USD', 'GBP'];
function convertToSek(amount, currency, fxRates) {
if (amount == null || Number.isNaN(amount)) return NaN;
if (currency === 'SEK') return amount;
if (!fxRates?.rates?.SEK || !fxRates?.rates?.[currency]) return NaN;
return amount * (fxRates.rates.SEK / fxRates.rates[currency]);
}
function SubscriptionForm({ initial, categories, onSave, onClose }) {
const [name, setName] = useState(initial?.name ?? '');
const [amount, setAmount] = useState(initial?.original_amount ?? initial?.amount ?? '');
const [currency, setCurrency] = useState(initial?.original_currency ?? 'SEK');
const [categoryId, setCategoryId] = useState(initial?.category_id ?? categories[0]?.id ?? '');
const [billingDay, setBillingDay] = useState(initial?.billing_day ?? '');
const [provider, setProvider] = useState(initial?.provider ?? '');
const [notes, setNotes] = useState(initial?.notes ?? '');
const [fxRates, setFxRates] = useState(null);
const [fxError, setFxError] = useState(null);
useEffect(() => {
if (currency === 'SEK') return;
let active = true;
setFxError(null);
api.getFxRates()
.then(data => {
if (!active) return;
setFxRates(data);
})
.catch(err => {
if (!active) return;
setFxError(err.message || 'Kunde inte hämta växelkurser.');
});
return () => {
active = false;
};
}, [currency]);
const numericAmount = parseAmount(amount);
const sekAmount = convertToSek(numericAmount, currency, fxRates);
function submit(e) {
e.preventDefault();
if (!name.trim() || isNaN(numericAmount) || !categoryId) return;
if (currency !== 'SEK' && isNaN(sekAmount)) return;
onSave({
name: name.trim(),
amount: currency === 'SEK' ? numericAmount : sekAmount,
original_amount: numericAmount,
original_currency: currency,
exchange_rate: currency === 'SEK' ? 1 : (fxRates.rates.SEK / fxRates.rates[currency]),
exchange_rate_date: currency === 'SEK' ? new Date().toISOString().slice(0, 10) : fxRates.date,
category_id: parseInt(categoryId),
billing_day: billingDay ? parseInt(billingDay) : null,
provider: provider.trim() || null,
notes: notes.trim() || null,
is_active: 1,
});
onClose();
}
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Namn</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={name}
onChange={e => setName(e.target.value)}
placeholder="t.ex. Oura"
required
autoFocus
/>
</div>
<div className="grid grid-cols-[1fr_110px] gap-2">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Belopp</label>
<input
type="text"
inputMode="decimal"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={amount}
onChange={e => setAmount(e.target.value)}
placeholder="5,99"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Valuta</label>
<select
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={currency}
onChange={e => setCurrency(e.target.value)}
>
{CURRENCY_OPTIONS.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
{currency !== 'SEK' && (
<div className="rounded-xl border border-blue-100 bg-blue-50 px-3 py-3 text-sm">
{fxError && <div className="text-red-700">{fxError}</div>}
{!fxError && fxRates && !Number.isNaN(sekAmount) && (
<div className="text-slate-700">
{amount || '0'} {currency} blir ungefär <strong>{fmt(sekAmount)}</strong>.
<div className="text-xs text-slate-500 mt-1">
Kursdatum: {fxRates.date} Källa: ECB dagskurs
</div>
</div>
)}
{!fxError && !fxRates && (
<div className="text-slate-600">Hämtar växelkurs...</div>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategori</label>
<select
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={categoryId}
onChange={e => setCategoryId(e.target.value)}
>
{categories.map(category => (
<option key={category.id} value={category.id}>{category.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Dras ungefär dag</label>
<input
type="number"
min="1"
max="31"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={billingDay}
onChange={e => setBillingDay(e.target.value)}
placeholder="t.ex. 27"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Leverantör</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={provider}
onChange={e => setProvider(e.target.value)}
placeholder="t.ex. Backblaze"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Anteckning</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Valfri notering"
/>
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-600 hover:bg-slate-50">
Avbryt
</button>
<button type="submit" className="flex-1 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
Spara
</button>
</div>
</form>
);
}
export default function SubscriptionsSection({
subscriptions,
categories,
monthLabel,
currentMonthBills,
onAdd,
onUpdate,
onDelete,
onAddToMonth,
onAddMissingToMonth,
}) {
const [modal, setModal] = useState(null);
const billBySubscriptionId = useMemo(
() => new Map(currentMonthBills.filter(bill => bill.subscription_id).map(bill => [bill.subscription_id, bill])),
[currentMonthBills]
);
const missingCount = subscriptions.filter(subscription => !billBySubscriptionId.has(subscription.id)).length;
const totalMonthly = subscriptions.reduce((sum, subscription) => sum + subscription.amount, 0);
return (
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden mb-3">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 gap-3">
<div className="flex items-center gap-2">
<Repeat size={16} className="text-violet-600" />
<span className="font-semibold text-slate-700 text-sm uppercase tracking-wide">Prenumerationer</span>
</div>
<div className="flex items-center gap-2">
{missingCount > 0 && (
<button onClick={onAddMissingToMonth} className="hidden sm:flex items-center gap-1 text-violet-600 text-xs font-medium hover:text-violet-700">
<BadgePlus size={14} /> Lägg till alla
</button>
)}
<button onClick={() => setModal('add')} className="flex items-center gap-1 text-blue-600 text-xs font-medium hover:text-blue-700">
<Plus size={14} /> Lägg till
</button>
</div>
</div>
<div className="px-4 py-3 border-b border-slate-100 bg-violet-50/60 text-sm text-slate-600">
Spara manuella prenumerationer som Oura och Backblaze en gång och för in dem i {monthLabel.toLowerCase()} utan dubletter.
</div>
<div className="divide-y divide-slate-50">
{subscriptions.map(subscription => {
const category = categories.find(item => item.id === subscription.category_id);
const currentBill = billBySubscriptionId.get(subscription.id);
return (
<div key={subscription.id} className="px-4 py-3 group">
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-800">{subscription.name}</span>
{category && <span className="text-[11px] px-2 py-0.5 rounded-full font-medium text-slate-600 bg-slate-100">{category.name}</span>}
{currentBill ? (
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-green-100 text-green-700">I månaden</span>
) : (
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700">Inte tillagd än</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
{subscription.provider && <span>{subscription.provider}</span>}
{subscription.billing_day && <span className="inline-flex items-center gap-1"><CalendarDays size={12} />Dag {subscription.billing_day}</span>}
{subscription.original_currency && subscription.original_currency !== 'SEK' && subscription.original_amount != null && (
<span>{subscription.original_amount} {subscription.original_currency}</span>
)}
{subscription.notes && <span>{subscription.notes}</span>}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-sm font-semibold text-slate-800">{fmt(subscription.amount)}</div>
<div className="mt-1 flex items-center justify-end gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
{!currentBill && (
<button onClick={() => onAddToMonth(subscription.id)} className="p-1 rounded hover:bg-violet-50 text-violet-600" title={`Lägg till i ${monthLabel}`}>
<CheckCircle2 size={14} />
</button>
)}
<button onClick={() => setModal({ edit: subscription })} className="p-1 rounded hover:bg-slate-100 text-slate-400">
<Pencil size={13} />
</button>
<button onClick={() => onDelete(subscription.id)} className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-500">
<Trash2 size={13} />
</button>
</div>
</div>
</div>
</div>
);
})}
{subscriptions.length === 0 && <p className="px-4 py-3 text-sm text-slate-400 italic">Inga prenumerationer tillagda än</p>}
</div>
<div className="px-4 py-2.5 bg-slate-50 border-t border-slate-100 flex justify-between items-center">
<span className="text-xs text-slate-500">
{missingCount > 0 ? `${missingCount} kvar att lägga in i ${monthLabel.toLowerCase()}` : `Alla ligger redan i ${monthLabel.toLowerCase()}`}
</span>
<span className="font-bold text-slate-700">{fmt(totalMonthly)}</span>
</div>
{modal === 'add' && (
<Modal title="Lägg till prenumeration" onClose={() => setModal(null)}>
<SubscriptionForm categories={categories} onSave={onAdd} onClose={() => setModal(null)} />
</Modal>
)}
{modal?.edit && (
<Modal title="Redigera prenumeration" onClose={() => setModal(null)}>
<SubscriptionForm initial={modal.edit} categories={categories} onSave={data => onUpdate(modal.edit.id, data)} onClose={() => setModal(null)} />
</Modal>
)}
</section>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { fmt } from '../utils.js';
import { TrendingDown, TrendingUp, Minus } from 'lucide-react';
export default function SummaryCard({ income, bills }) {
const mandatory = income.filter(i => i.type !== 'optional' && i.type !== 'benefit');
const optional = income.filter(i => i.type === 'optional');
const benefits = income.filter(i => i.type === 'benefit');
const totalMandatoryIncome = mandatory.reduce((s, i) => s + i.amount, 0);
const totalOptional = optional.reduce((s, i) => s + i.amount, 0);
const totalBenefits = benefits.reduce((s, i) => s + i.amount, 0);
const totalBills = bills.reduce((s, b) => s + b.amount, 0);
const remaining = totalMandatoryIncome - totalBills;
const remainingWithOptional = remaining + totalOptional;
const paidBills = bills.filter(b => b.is_paid).reduce((s, b) => s + b.amount, 0);
const unpaidBills = bills.filter(b => !b.is_paid).reduce((s, b) => s + b.amount, 0);
function Row({ label, value, sub, color, bold, divider }) {
return (
<>
{divider && <div className="border-t border-slate-200 my-1" />}
<div className={`flex justify-between items-center py-1.5 ${bold ? 'font-semibold' : ''}`}>
<span className={`text-sm ${bold ? 'text-slate-800' : 'text-slate-600'}`}>{label}</span>
<div className="text-right">
<span className={`text-sm font-semibold ${color ?? 'text-slate-700'}`}>{value}</span>
{sub && <span className="text-xs text-slate-400 ml-2">{sub}</span>}
</div>
</div>
</>
);
}
return (
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden mb-3">
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-100">
<Minus size={16} className="text-slate-400" />
<span className="font-semibold text-slate-700 text-sm uppercase tracking-wide">Sammanfattning</span>
</div>
<div className="px-4 py-3 space-y-0.5">
<Row label="Lön & obligatorisk inkomst" value={fmt(totalMandatoryIncome)} color="text-green-600" />
<Row label="Totala utgifter" value={`${fmt(totalBills)}`} color="text-red-500"
sub={`${bills.length} poster`} />
<Row
label="Kvar (obligatoriskt)"
value={fmt(remaining)}
color={remaining >= 0 ? 'text-blue-600' : 'text-red-600'}
bold
divider
/>
{totalOptional > 0 && (
<>
<Row label="Valfria inkomster" value={`+${fmt(totalOptional)}`} color="text-amber-600" />
<Row
label="Kvar totalt (inkl. valfria)"
value={fmt(remainingWithOptional)}
color={remainingWithOptional >= 0 ? 'text-green-600' : 'text-red-600'}
bold
divider
/>
</>
)}
{totalBenefits > 0 && (
<Row label={`ePassi / förmåner`} value={fmt(totalBenefits)} color="text-purple-600"
sub="kan ej tas ut kontant" />
)}
</div>
{bills.length > 0 && (
<div className="px-4 py-2.5 bg-slate-50 border-t border-slate-100 flex gap-4">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-green-400" />
<span className="text-xs text-slate-500">Betalt: <strong>{fmt(paidBills)}</strong></span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-slate-300" />
<span className="text-xs text-slate-500">Ej betalt: <strong>{fmt(unpaidBills)}</strong></span>
</div>
</div>
)}
</section>
);
}
+55
View File
@@ -0,0 +1,55 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
height: 100%;
-webkit-text-size-adjust: 100%;
background: #f8fafc;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
#root {
min-height: 100%;
}
body {
@apply bg-slate-50 text-slate-800 font-sans antialiased;
min-height: 100vh;
min-height: 100dvh;
margin: 0;
overscroll-behavior-y: none;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
input,
textarea,
select,
button {
font: inherit;
border-radius: 0;
}
@media (max-width: 768px) {
input,
textarea,
select {
font-size: 16px;
}
}
}
@layer utilities {
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.ios-safe-top { padding-top: env(safe-area-inset-top, 0px); }
.ios-safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
.ios-safe-left { padding-left: env(safe-area-inset-left, 0px); }
.ios-safe-right { padding-right: env(safe-area-inset-right, 0px); }
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { registerSW } from 'virtual:pwa-register';
import App from './App.jsx';
import './index.css';
registerSW({ immediate: true });
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+772
View File
@@ -0,0 +1,772 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import { Bot, ChevronDown, Eye, History, ImagePlus, Info, Loader2, MessageSquarePlus, SendHorizontal, Sparkles, Trash2, Wallet } from 'lucide-react';
import { api } from '../api.js';
const SUGGESTIONS = [
'Har jag råd med den här?',
'Hur ser min ekonomi ut den här månaden?',
'Vilka abonnemang borde jag ifrågasätta just nu?',
'Ser du något konstigt i mina senaste transaktioner?',
];
function renderInlineFormatting(text) {
const parts = String(text || '').split(/(\*\*[^*]+\*\*)/g);
return parts.filter(Boolean).map((part, index) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={`${part}-${index}`}>{part.slice(2, -2)}</strong>;
}
return <span key={`${part}-${index}`}>{part}</span>;
});
}
function MarkdownishMessage({ content }) {
const lines = String(content || '').split('\n');
const blocks = [];
for (let index = 0; index < lines.length;) {
const rawLine = lines[index];
const line = rawLine.trimEnd();
if (!line.trim()) {
index += 1;
continue;
}
if (/^\|.+\|$/.test(line) && index + 1 < lines.length && /^\|\s*[:\-]/.test(lines[index + 1].trim())) {
const tableLines = [line];
index += 2;
while (index < lines.length && /^\|.+\|$/.test(lines[index].trim())) {
tableLines.push(lines[index].trim());
index += 1;
}
const rows = tableLines.map(tableLine => tableLine.split('|').slice(1, -1).map(cell => cell.trim()));
const [header, ...body] = rows;
blocks.push(
<div key={`table-${blocks.length}`} className="overflow-x-auto">
<table className="min-w-full border-collapse rounded-xl overflow-hidden border border-slate-200 text-sm">
<thead className="bg-slate-100">
<tr>
{header.map((cell, cellIndex) => (
<th key={`head-${cellIndex}`} className="border-b border-slate-200 px-3 py-2 text-left font-semibold text-slate-700">
{renderInlineFormatting(cell)}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`} className="bg-white even:bg-slate-50">
{row.map((cell, cellIndex) => (
<td key={`cell-${rowIndex}-${cellIndex}`} className="border-t border-slate-100 px-3 py-2 text-slate-700 align-top">
{renderInlineFormatting(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
continue;
}
if (line.startsWith('### ')) {
blocks.push(
<h3 key={`h3-${blocks.length}`} className="text-base font-semibold text-slate-900">
{renderInlineFormatting(line.slice(4))}
</h3>
);
index += 1;
continue;
}
if (line.startsWith('## ')) {
blocks.push(
<h2 key={`h2-${blocks.length}`} className="text-lg font-semibold text-slate-900">
{renderInlineFormatting(line.slice(3))}
</h2>
);
index += 1;
continue;
}
if (/^\*\s+/.test(line)) {
const items = [];
while (index < lines.length && /^\*\s+/.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^\*\s+/, ''));
index += 1;
}
blocks.push(
<ul key={`ul-${blocks.length}`} className="list-disc pl-5 space-y-1 text-slate-700">
{items.map((item, itemIndex) => (
<li key={`li-${itemIndex}`}>{renderInlineFormatting(item)}</li>
))}
</ul>
);
continue;
}
if (/^\d+\.\s+/.test(line)) {
const items = [];
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^\d+\.\s+/, ''));
index += 1;
}
blocks.push(
<ol key={`ol-${blocks.length}`} className="list-decimal pl-5 space-y-1 text-slate-700">
{items.map((item, itemIndex) => (
<li key={`oli-${itemIndex}`}>{renderInlineFormatting(item)}</li>
))}
</ol>
);
continue;
}
const paragraphLines = [line];
index += 1;
while (
index < lines.length
&& lines[index].trim()
&& !lines[index].trim().startsWith('##')
&& !lines[index].trim().startsWith('###')
&& !/^\*\s+/.test(lines[index].trim())
&& !/^\d+\.\s+/.test(lines[index].trim())
&& !/^\|.+\|$/.test(lines[index].trim())
) {
paragraphLines.push(lines[index].trim());
index += 1;
}
blocks.push(
<p key={`p-${blocks.length}`} className="text-slate-700 leading-7">
{renderInlineFormatting(paragraphLines.join(' '))}
</p>
);
}
return <div className="space-y-3">{blocks}</div>;
}
function readFileAsAttachment(file) {
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = String(reader.result || '');
resolve({
name: file.name,
size: file.size,
mime_type: file.type || 'image/png',
data_base64: dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl,
preview: dataUrl,
});
};
reader.readAsDataURL(file);
});
}
function statusStyle(status) {
if (status === 'included') return 'bg-emerald-50 text-emerald-700 border-emerald-200';
if (status === 'missing') return 'bg-amber-50 text-amber-700 border-amber-200';
return 'bg-slate-50 text-slate-600 border-slate-200';
}
function prettyStatus(status) {
if (status === 'included') return 'Används';
if (status === 'missing') return 'Saknas';
return 'Av';
}
function ImagePreview({ item, onRemove }) {
return (
<div className="rounded-2xl border border-slate-200 bg-white overflow-hidden shadow-sm">
<img src={item.preview} alt={item.name} className="h-32 w-full object-cover" />
<div className="flex items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-slate-800">{item.name}</div>
<div className="text-xs text-slate-500">{Math.round(item.size / 1024)} KB</div>
</div>
<button
type="button"
onClick={onRemove}
className="rounded-lg border border-slate-200 px-2 py-1 text-xs text-slate-600 hover:bg-slate-50"
>
Ta bort
</button>
</div>
</div>
);
}
function AssistantDetails({ entry }) {
const [open, setOpen] = useState(false);
if (!entry.context_preview && !entry.context_meta) return null;
return (
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50 overflow-hidden">
<button
type="button"
onClick={() => setOpen(value => !value)}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-slate-100"
>
<span className="flex items-center gap-2">
<Info size={14} className="text-blue-600" />
Visa underlag och bedömning
</span>
<ChevronDown size={16} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="space-y-4 border-t border-slate-200 px-3 py-3">
{entry.context_meta?.sources?.length > 0 && (
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">Datakällor</div>
<div className="mt-2 grid gap-2">
{entry.context_meta.sources.map(source => (
<div key={source.key} className="rounded-xl border border-slate-200 bg-white px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-slate-800">{source.label}</div>
<span className={`rounded-full border px-2.5 py-1 text-xs font-medium ${statusStyle(source.status)}`}>
{prettyStatus(source.status)}
</span>
</div>
<div className="mt-1 text-sm text-slate-600">{source.summary}</div>
</div>
))}
</div>
</div>
)}
{entry.context_meta?.method?.length > 0 && (
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">Hur AI:n gör bedömningen</div>
<div className="mt-2 space-y-2">
{entry.context_meta.method.map((step, index) => (
<div key={`${index}-${step}`} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm text-slate-700">
{step}
</div>
))}
</div>
</div>
)}
{entry.context_preview && (
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">Kontext som skickades till Ollama</div>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words rounded-xl border border-slate-200 bg-white px-3 py-3 text-xs text-slate-700">
{entry.context_preview}
</pre>
</div>
)}
</div>
)}
</div>
);
}
function formatConversationDate(value) {
if (!value) return '';
try {
return new Date(value).toLocaleString('sv-SE', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '';
}
}
function getConversationPreview(conversation) {
const preview = String(conversation?.preview || '').replace(/\s+/g, ' ').trim();
if (preview) return preview.length > 84 ? `${preview.slice(0, 81)}...` : preview;
return 'Ingen sammanfattning ännu';
}
export default function AIPage() {
const [settings, setSettings] = useState(null);
const [message, setMessage] = useState('');
const [attachments, setAttachments] = useState([]);
const [history, setHistory] = useState([]);
const [conversations, setConversations] = useState([]);
const [activeConversationId, setActiveConversationId] = useState(null);
const [deletingConversationId, setDeletingConversationId] = useState(null);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
const pasteZoneRef = useRef(null);
useEffect(() => {
let active = true;
Promise.all([api.getAiSettings(), api.getAiConversations()])
.then(([settingsResult, conversationResult]) => {
if (!active) return;
const loadedConversations = conversationResult?.conversations || [];
setSettings(settingsResult);
setConversations(loadedConversations);
if (loadedConversations[0]) {
setActiveConversationId(loadedConversations[0].id);
setHistory(loadedConversations[0].messages || []);
} else {
setHistory([]);
}
})
.catch(err => {
if (!active) return;
setError(err.message || 'Kunde inte ladda AI-inställningarna.');
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
const activeConversation = useMemo(
() => conversations.find(conversation => conversation.id === activeConversationId) || null,
[conversations, activeConversationId]
);
const attachmentLabel = useMemo(() => (
attachments.length > 0
? `${attachments.length} bild${attachments.length === 1 ? '' : 'er'} redo att skickas`
: 'Ingen bild vald ännu'
), [attachments]);
function useSuggestion(text) {
setError(null);
setMessage(text);
}
function sortConversations(items) {
return [...items].sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')));
}
function handleSelectConversation(conversation) {
setActiveConversationId(conversation.id);
setHistory(conversation.messages || []);
setAttachments([]);
setError(null);
}
function handleStartNewConversation() {
setActiveConversationId(null);
setHistory([]);
setMessage('');
setAttachments([]);
setError(null);
}
async function handleDeleteConversation(conversationId) {
setDeletingConversationId(conversationId);
try {
await api.deleteAiConversation(conversationId);
setConversations(current => {
const remaining = current.filter(conversation => conversation.id !== conversationId);
if (activeConversationId === conversationId) {
if (remaining[0]) {
setActiveConversationId(remaining[0].id);
setHistory(remaining[0].messages || []);
} else {
handleStartNewConversation();
}
}
return remaining;
});
} catch (err) {
setError(err.message || 'Kunde inte radera AI-chatten.');
} finally {
setDeletingConversationId(null);
}
}
async function addFiles(files) {
const imageFiles = files.filter(file => file.type?.startsWith('image/'));
if (imageFiles.length === 0) return;
setError(null);
const items = await Promise.all(imageFiles.map(readFileAsAttachment));
setAttachments(current => [...current, ...items]);
}
async function handleFilesSelected(event) {
const files = Array.from(event.target.files || []);
await addFiles(files);
event.target.value = '';
}
async function handlePaste(event) {
const items = Array.from(event.clipboardData?.items || []);
const files = items
.filter(item => item.kind === 'file' && item.type.startsWith('image/'))
.map(item => item.getAsFile())
.filter(Boolean);
if (files.length === 0) return;
event.preventDefault();
await addFiles(files);
}
async function handleSubmit(event) {
event.preventDefault();
const trimmed = message.trim();
if (!trimmed) return;
if (attachments.length > 0 && !settings?.vision_model) {
setError('Du har bifogat bild men ingen vision-modell är vald. Gå till Inställningar och välj en bildmodell, till exempel qwen3.5:9b.');
return;
}
const userEntry = {
id: `user-${Date.now()}`,
role: 'user',
content: trimmed,
attachments,
created_at: new Date().toISOString(),
};
setSending(true);
setError(null);
setHistory(current => [...current, userEntry]);
try {
const result = await api.chatWithAi({
conversation_id: activeConversationId || undefined,
message: trimmed,
images: attachments.map(item => ({
name: item.name,
mime_type: item.mime_type,
data_base64: item.data_base64,
})),
});
if (result.conversation) {
const savedConversation = result.conversation;
setConversations(current => sortConversations([
savedConversation,
...current.filter(conversation => conversation.id !== savedConversation.id),
]));
setActiveConversationId(savedConversation.id);
setHistory(savedConversation.messages || []);
} else {
setHistory(current => [
...current,
{
id: `assistant-${Date.now()}`,
role: 'assistant',
content: result.message || 'AI:n svarade tomt.',
model: result.model,
created_at: result.created_at,
context_preview: result.context_preview || '',
context_meta: result.context_meta || null,
},
]);
}
setMessage('');
setAttachments([]);
} catch (err) {
setError(err.message || 'Kunde inte fråga AI just nu.');
setHistory(current => current.filter(item => item.id !== userEntry.id));
} finally {
setSending(false);
}
}
if (loading) {
return (
<div className="flex justify-center items-center py-20">
<Loader2 size={24} className="animate-spin text-blue-500" />
</div>
);
}
if (error && !settings) {
return (
<div className="mt-8 bg-red-50 border border-red-200 rounded-2xl px-5 py-4 text-red-700 text-sm">
{error}
</div>
);
}
if (!settings?.enabled || !settings?.model) {
return (
<div className="space-y-4 pt-6">
<section className="rounded-[28px] border border-slate-200 bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-6 text-white">
<div className="flex items-center gap-2 text-sm text-blue-200">
<Bot size={16} />
AI
</div>
<h1 className="mt-3 text-3xl font-semibold tracking-tight">Din ekonomico-pilot är nästan redo</h1>
<p className="mt-3 max-w-2xl text-sm text-slate-200">
Aktivera Ollama i inställningarna och välj minst en textmodell. Vill du kunna skicka skärmdumpar behöver du också välja en vision-modell.
</p>
</section>
<section className="rounded-3xl border border-slate-200 bg-white px-5 py-5">
<div className="flex items-start gap-3">
<div className="rounded-2xl bg-blue-100 p-3 text-blue-700">
<Wallet size={18} />
</div>
<div>
<div className="text-lg font-semibold text-slate-900"> här kommer det fungera</div>
<p className="mt-1 text-sm text-slate-600">
AI-fliken kan resonera utifrån budget, prenumerationer, banksaldo och senaste transaktioner. Den kan också titta en skärmdump och svara saker som om köpet är vettigt just nu.
</p>
<a
href="/installningar"
className="mt-4 inline-flex rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
Öppna inställningar
</a>
</div>
</div>
</section>
</div>
);
}
return (
<div className="space-y-4 pt-6">
<section className="rounded-[28px] border border-slate-200 bg-[radial-gradient(circle_at_top_left,_rgba(96,165,250,0.24),_transparent_38%),linear-gradient(135deg,#020617,#0f172a_55%,#1d4ed8)] px-4 py-5 text-white shadow-sm sm:rounded-[32px] sm:px-6 sm:py-6">
<div className="flex flex-wrap items-center gap-2 text-sm text-blue-100">
<Bot size={16} />
<span>AI</span>
<span className="rounded-full bg-white/10 px-2.5 py-1 text-xs text-white/90">
Text: {settings.model}
</span>
{settings.vision_model && (
<span className="rounded-full bg-emerald-400/20 px-2.5 py-1 text-xs text-emerald-200">
Vision: {settings.vision_model}
</span>
)}
</div>
<h1 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">Fråga din ekonomi innan du gör något dumt</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-200">
Ställ frågor, klistra in en skärmdump med Ctrl+V eller ladda upp en bild. AI:n visar också vilket underlag den faktiskt använde när den drog sin slutsats.
</p>
</section>
<section className="grid gap-4 xl:grid-cols-[minmax(320px,0.9fr)_minmax(0,1.1fr)]">
<div className="order-2 min-w-0 space-y-4 xl:order-1">
<div className="rounded-3xl border border-slate-200 bg-white p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<History size={16} className="text-blue-600" />
Tidigare AI-chattar
</div>
<button
type="button"
onClick={handleStartNewConversation}
className="inline-flex items-center gap-1.5 rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs font-medium text-slate-700 hover:border-blue-200 hover:bg-blue-50"
>
<MessageSquarePlus size={14} />
Ny chatt
</button>
</div>
{conversations.length === 0 ? (
<div className="mt-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-500">
Dina AI-chattar sparas här när du börjar använda ekonomichatten.
</div>
) : (
<div className="mt-4 space-y-2">
{conversations.map(conversation => (
<div
key={conversation.id}
className={`rounded-2xl border px-3 py-3 transition ${
activeConversationId === conversation.id
? 'border-blue-200 bg-blue-50'
: 'border-slate-200 bg-slate-50'
}`}
>
<div className="flex items-start gap-2">
<button
type="button"
onClick={() => handleSelectConversation(conversation)}
className="min-w-0 flex-1 text-left"
>
<div className="truncate text-sm font-medium text-slate-800">{conversation.title || 'AI-chatt'}</div>
<div className="mt-1 line-clamp-2 text-xs text-slate-500">{getConversationPreview(conversation)}</div>
<div className="mt-2 text-[11px] text-slate-400">
{conversation.message_count || 0} meddelanden {formatConversationDate(conversation.updated_at)}
</div>
</button>
<button
type="button"
onClick={() => handleDeleteConversation(conversation.id)}
className="rounded-xl p-2 text-slate-400 hover:bg-white hover:text-red-600 disabled:opacity-50"
disabled={deletingConversationId === conversation.id}
aria-label="Radera AI-chatt"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 sm:p-5">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<Sparkles size={16} className="text-violet-600" />
Snabbfrågor
</div>
<div className="mt-4 grid gap-2">
{SUGGESTIONS.map(item => (
<button
key={item}
type="button"
onClick={() => useSuggestion(item)}
className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-left text-sm text-slate-700 hover:border-blue-200 hover:bg-blue-50 hover:text-slate-900"
>
{item}
</button>
))}
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 sm:p-5">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<Eye size={16} className="text-emerald-600" />
Bildunderlag
</div>
<p className="mt-1 text-sm text-slate-500">
Klistra in med Ctrl+V direkt här eller ladda upp skärmdumpar, kvitton och prisbilder. Beskriv sedan i prompten vad du vill att AI:n ska titta efter.
</p>
<div
ref={pasteZoneRef}
onPaste={handlePaste}
className="mt-4 rounded-2xl border border-dashed border-slate-300 bg-[linear-gradient(180deg,#f8fafc_0%,#eef2ff_100%)] px-3 py-4 sm:px-4 sm:py-5"
>
<label className="flex cursor-pointer items-center justify-center gap-2 rounded-2xl border border-slate-200 bg-white px-4 py-6 text-sm text-slate-600 hover:border-blue-300 hover:bg-blue-50">
<ImagePlus size={18} />
Klicka för att lägga till bild eller tryck Ctrl+V här
<input type="file" accept="image/*" multiple className="hidden" onChange={handleFilesSelected} />
</label>
<div className="mt-3 text-xs text-slate-500">{attachmentLabel}</div>
<div className="mt-1 text-xs text-slate-500">
Aktiv vision-modell: {settings?.vision_model || 'ingen vald'}
</div>
{!settings?.vision_model && (
<div className="mt-2 rounded-2xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
För bildanalys behöver du välja en vision-modell i Inställningar. `qwen3.5:9b` är ett bra val hos dig just nu.
</div>
)}
</div>
{attachments.length > 0 && (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{attachments.map((item, index) => (
<ImagePreview
key={`${item.name}-${index}`}
item={item}
onRemove={() => setAttachments(current => current.filter((_, itemIndex) => itemIndex !== index))}
/>
))}
</div>
)}
</div>
</div>
<div className="order-1 min-w-0 rounded-3xl border border-slate-200 bg-white p-4 shadow-sm sm:p-5 xl:order-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-800">{activeConversation?.title || 'Ekonomichatt'}</div>
<div className="text-sm text-slate-500">Svarar svenska och visar vad som faktiskt låg till grund för slutsatsen.</div>
</div>
</div>
<div className="mt-4 h-[44dvh] min-h-[320px] overflow-y-auto rounded-3xl bg-[linear-gradient(180deg,#f8fafc_0%,#eef2ff_100%)] p-3 sm:h-[460px]">
{history.length === 0 ? (
<div className="flex h-full items-center justify-center text-center text-sm text-slate-500">
Börja med en fråga som "Har jag råd med den här?" och klistra gärna in en bild om du vill att AI:n ska väga in den.
</div>
) : (
<div className="space-y-3">
{history.map(entry => (
<div
key={entry.id}
className={`min-w-0 rounded-3xl px-4 py-3 text-sm shadow-sm ${
entry.role === 'user'
? 'ml-3 bg-blue-600 text-white sm:ml-8'
: 'mr-3 border border-slate-200 bg-white text-slate-800 sm:mr-8'
}`}
>
<div className="leading-6">
{entry.role === 'assistant'
? <MarkdownishMessage content={entry.content} />
: <div className="whitespace-pre-wrap">{entry.content}</div>}
</div>
{(entry.attachments?.length ?? 0) > 0 && (
<div className="mt-2 text-xs opacity-80">
{entry.attachments.length} bild{entry.attachments.length === 1 ? '' : 'er'} bifogad{entry.attachments.length === 1 ? '' : 'e'}.
</div>
)}
{entry.model && (
<div className="mt-2 text-xs text-slate-500">
Modell: {entry.model}
</div>
)}
{entry.role === 'assistant' && <AssistantDetails entry={entry} />}
</div>
))}
{sending && (
<div className="mr-3 rounded-3xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow-sm sm:mr-8">
<div className="flex items-center gap-2">
<Loader2 size={15} className="animate-spin text-blue-600" />
Tänker...
</div>
</div>
)}
</div>
)}
</div>
{error && (
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
<div>{error}</div>
{history.length > 0 && (
<div className="mt-1 text-xs text-red-600/90">
Historiken ovan är tidigare svar i den valda chatten. Felet gäller bara senaste försöket att skicka din nya fråga.
</div>
)}
</div>
)}
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<textarea
className="min-h-[132px] w-full rounded-3xl border border-slate-200 px-4 py-3 text-sm leading-6 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={message}
onChange={event => {
setError(null);
setMessage(event.target.value);
}}
onPaste={handlePaste}
placeholder="Exempel: Jag klistrar in en skärmdump från en butik. Titta på priset, jämför det med min budget och säg om köpet känns rimligt just nu."
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="rounded-full bg-slate-100 px-3 py-1.5 text-xs text-slate-600">
{attachmentLabel}
</div>
<button
type="submit"
className="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-blue-600 px-4 py-3 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 sm:w-auto sm:py-2.5"
disabled={sending || !message.trim()}
>
<SendHorizontal size={15} />
Skicka
</button>
</div>
</form>
</div>
</section>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { api } from '../api.js';
import { monthLabel } from '../utils.js';
import MonthNav from '../components/MonthNav.jsx';
import IncomeSection from '../components/IncomeSection.jsx';
import CategorySection from '../components/CategorySection.jsx';
import SummaryCard from '../components/SummaryCard.jsx';
import SubscriptionsSection from '../components/SubscriptionsSection.jsx';
function sortSubscriptions(items) {
return [...items].sort((a, b) => (a.billing_day ?? 99) - (b.billing_day ?? 99) || a.name.localeCompare(b.name));
}
export default function BudgetPage() {
const { year, month } = useParams();
const y = parseInt(year);
const m = parseInt(month);
const [data, setData] = useState(null);
const [subscriptions, setSubscriptions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [monthData, subscriptionsData] = await Promise.all([
api.getMonth(y, m),
api.getSubscriptions(),
]);
setData(monthData);
setSubscriptions(sortSubscriptions(subscriptionsData));
} catch (e) {
setError('Kunde inte ladda budgeten. Kontrollera att servern är igång.');
} finally {
setLoading(false);
}
}, [y, m]);
useEffect(() => {
load();
}, [load]);
async function handleAddIncome(monthId, incomeData) {
const item = await api.addIncome(monthId, incomeData);
setData(d => ({ ...d, income: [...d.income, item] }));
}
async function handleUpdateIncome(id, incomeData) {
const item = await api.updateIncome(id, incomeData);
setData(d => ({ ...d, income: d.income.map(i => i.id === id ? item : i) }));
}
async function handleDeleteIncome(id) {
await api.deleteIncome(id);
setData(d => ({ ...d, income: d.income.filter(i => i.id !== id) }));
}
async function handleAddBill(monthId, billData) {
const bill = await api.addBill(monthId, billData);
setData(d => ({ ...d, bills: [...d.bills, bill] }));
}
async function handleUpdateBill(id, billData) {
const bill = await api.updateBill(id, billData);
setData(d => ({ ...d, bills: d.bills.map(b => b.id === id ? bill : b) }));
}
async function handleToggleBill(id) {
const bill = await api.toggleBill(id);
setData(d => ({ ...d, bills: d.bills.map(b => b.id === id ? bill : b) }));
}
async function handleDeleteBill(id) {
await api.deleteBill(id);
setData(d => ({ ...d, bills: d.bills.filter(b => b.id !== id) }));
}
async function handleAddSubscription(subscriptionData) {
const created = await api.addSubscription(subscriptionData);
setSubscriptions(items => sortSubscriptions([...items, created]));
}
async function handleUpdateSubscription(id, subscriptionData) {
const updated = await api.updateSubscription(id, subscriptionData);
setSubscriptions(items => sortSubscriptions(items.map(item => item.id === id ? updated : item)));
}
async function handleDeleteSubscription(id) {
await api.deleteSubscription(id);
setSubscriptions(items => items.filter(item => item.id !== id));
}
async function handleAddSubscriptionToMonth(subscriptionId) {
const bill = await api.addSubscriptionToMonth(subscriptionId, data.id);
setData(d => ({ ...d, bills: [...d.bills, bill] }));
}
async function handleAddMissingSubscriptionsToMonth() {
const createdBills = await api.addMissingSubscriptionsToMonth(data.id);
if (createdBills.length === 0) return;
setData(d => ({ ...d, bills: [...d.bills, ...createdBills] }));
}
if (loading) {
return (
<div className="flex justify-center items-center py-20">
<Loader2 size={24} className="animate-spin text-blue-500" />
</div>
);
}
if (error) {
return (
<div className="mt-8 bg-red-50 border border-red-200 rounded-2xl px-5 py-4 text-red-700 text-sm">
{error}
</div>
);
}
const { income = [], bills = [], categories = [] } = data ?? {};
return (
<div>
<MonthNav year={y} month={m} />
<SubscriptionsSection
subscriptions={subscriptions}
categories={categories}
monthLabel={monthLabel(y, m)}
currentMonthBills={bills}
onAdd={handleAddSubscription}
onUpdate={handleUpdateSubscription}
onDelete={handleDeleteSubscription}
onAddToMonth={handleAddSubscriptionToMonth}
onAddMissingToMonth={handleAddMissingSubscriptionsToMonth}
/>
<IncomeSection
income={income}
monthId={data.id}
onAdd={handleAddIncome}
onUpdate={handleUpdateIncome}
onDelete={handleDeleteIncome}
/>
{categories.map(cat => {
const catBills = bills.filter(b => b.category_id === cat.id);
return (
<CategorySection
key={cat.id}
category={cat}
bills={catBills}
allCategories={categories}
monthId={data.id}
onAdd={handleAddBill}
onUpdate={handleUpdateBill}
onToggle={handleToggleBill}
onDelete={handleDeleteBill}
/>
);
})}
<SummaryCard income={income} bills={bills} />
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { useState, useEffect } from 'react';
import { api } from '../api.js';
import LoanCard from '../components/LoanCard.jsx';
import Modal from '../components/Modal.jsx';
import { Plus, Loader2, CreditCard } from 'lucide-react';
import { fmt, parseAmount } from '../utils.js';
function LoanForm({ initial, onSave, onClose }) {
const [name, setName] = useState(initial?.name ?? '');
const [original, setOriginal] = useState(initial?.original_amount ?? '');
const [balance, setBalance] = useState(initial?.current_balance ?? '');
const [monthly, setMonthly] = useState(initial?.monthly_payment ?? '');
const [rate, setRate] = useState(initial?.interest_rate ?? '');
const [startDate, setStartDate] = useState(initial?.start_date ?? '');
const [notes, setNotes] = useState(initial?.notes ?? '');
function submit(e) {
e.preventDefault();
if (!name.trim() || isNaN(parseAmount(original)) || isNaN(parseAmount(monthly))) return;
onSave({
name: name.trim(),
original_amount: parseAmount(original),
current_balance: balance ? parseAmount(balance) : parseAmount(original),
monthly_payment: parseAmount(monthly),
interest_rate: rate ? parseAmount(rate) : 0,
start_date: startDate || null,
notes: notes.trim() || null,
is_active: 1,
});
onClose();
}
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Namn</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={name} onChange={e => setName(e.target.value)} placeholder="t.ex. Bolån" required autoFocus
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Ursprungligt belopp</label>
<input type="text" inputMode="decimal"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={original} onChange={e => setOriginal(e.target.value)} placeholder="500 000" required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nuvarande saldo</label>
<input type="text" inputMode="decimal"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={balance} onChange={e => setBalance(e.target.value)} placeholder="Lämna tomt = original"
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Månadsbetalning</label>
<input type="text" inputMode="decimal"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={monthly} onChange={e => setMonthly(e.target.value)} placeholder="5 000" required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Ränta (%)</label>
<input type="text" inputMode="decimal"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={rate} onChange={e => setRate(e.target.value)} placeholder="0"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Startdatum (valfri)</label>
<input type="date"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={startDate} onChange={e => setStartDate(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Anteckning (valfri)</label>
<input
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={notes} onChange={e => setNotes(e.target.value)} placeholder="Valfri notering"
/>
</div>
<div className="flex flex-col gap-2 pt-1 sm:flex-row">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-200 text-sm font-medium text-slate-600 hover:bg-slate-50">
Avbryt
</button>
<button type="submit" className="flex-1 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
Spara
</button>
</div>
</form>
);
}
export default function LoansPage() {
const [loans, setLoans] = useState([]);
const [payments, setPayments] = useState({});
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState(null);
const [showInactive, setShowInactive] = useState(false);
useEffect(() => {
api.getLoans().then(data => {
setLoans(data);
setLoading(false);
data.forEach(loan => {
api.getLoanPayments(loan.id).then(pmts => {
setPayments(prev => ({ ...prev, [loan.id]: pmts }));
});
});
}).catch(() => setLoading(false));
}, []);
async function handleAdd(data) {
const loan = await api.addLoan(data);
setLoans(l => [...l, loan]);
}
async function handleUpdate(id, data) {
const loan = await api.updateLoan(id, data);
setLoans(l => l.map(x => x.id === id ? loan : x));
}
async function handleDelete(id) {
await api.deleteLoan(id);
setLoans(l => l.map(x => x.id === id ? { ...x, is_active: 0 } : x));
}
async function handlePayment(loanId, data) {
const result = await api.addLoanPayment(loanId, data);
setLoans(l => l.map(x => x.id === loanId ? result.loan : x));
setPayments(prev => ({
...prev,
[loanId]: [result.payment, ...(prev[loanId] ?? [])],
}));
}
const active = loans.filter(l => l.is_active);
const inactive = loans.filter(l => !l.is_active);
const totalBalance = active.reduce((s, l) => s + l.current_balance, 0);
const totalMonthly = active.reduce((s, l) => s + l.monthly_payment, 0);
if (loading) return (
<div className="flex justify-center items-center py-20">
<Loader2 size={24} className="animate-spin text-blue-500" />
</div>
);
return (
<div>
<div className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="font-bold text-xl text-slate-800">Lån & krediter</h1>
{active.length > 0 && (
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div className="rounded-2xl border border-rose-100 bg-rose-50 px-3 py-2 text-sm text-rose-800">
Totalt kvar att betala: <strong>{fmt(totalBalance)}</strong>
</div>
<div className="rounded-2xl border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-800">
Månadsbelastning: <strong>{fmt(totalMonthly)}</strong>
</div>
</div>
)}
</div>
<button
onClick={() => setModal('add')}
className="flex w-full items-center justify-center gap-1.5 rounded-xl bg-blue-600 px-3.5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 sm:w-auto"
>
<Plus size={15} /> Nytt lån
</button>
</div>
{active.length === 0 && inactive.length === 0 && (
<div className="text-center py-16 text-slate-400">
<CreditCard size={40} className="mx-auto mb-3 opacity-30" />
<p className="text-sm">Inga lån tillagda ännu</p>
<button onClick={() => setModal('add')} className="mt-3 text-blue-600 text-sm font-medium hover:underline">
Lägg till ditt första lån
</button>
</div>
)}
{active.map(loan => (
<LoanCard
key={loan.id}
loan={loan}
payments={payments[loan.id] ?? []}
onPayment={handlePayment}
onEdit={() => setModal({ edit: loan })}
onDelete={() => handleDelete(loan.id)}
/>
))}
{inactive.length > 0 && (
<div className="mt-2">
<button
onClick={() => setShowInactive(s => !s)}
className="text-xs text-slate-400 hover:text-slate-600 mb-2"
>
{showInactive ? 'Dölj' : 'Visa'} avslutade lån ({inactive.length})
</button>
{showInactive && inactive.map(loan => (
<LoanCard
key={loan.id}
loan={loan}
payments={payments[loan.id] ?? []}
onPayment={handlePayment}
onEdit={() => setModal({ edit: loan })}
onDelete={() => {}}
/>
))}
</div>
)}
{modal === 'add' && (
<Modal title="Lägg till lån" onClose={() => setModal(null)}>
<LoanForm onSave={handleAdd} onClose={() => setModal(null)} />
</Modal>
)}
{modal?.edit && (
<Modal title="Redigera lån" onClose={() => setModal(null)}>
<LoanForm
initial={modal.edit}
onSave={(data) => handleUpdate(modal.edit.id, data)}
onClose={() => setModal(null)}
/>
</Modal>
)}
</div>
);
}
+474
View File
@@ -0,0 +1,474 @@
import { useCallback, useEffect, useState } from 'react';
import { Loader2, Settings } from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api.js';
import AppSettingsSection from '../components/AppSettingsSection.jsx';
import AISettingsSection from '../components/AISettingsSection.jsx';
import CategorySettingsSection from '../components/CategorySettingsSection.jsx';
import EnableBankingSection from '../components/EnableBankingSection.jsx';
import OidcSettingsSection from '../components/OidcSettingsSection.jsx';
function sortCategories(items) {
return [...items].sort((a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name));
}
export default function SettingsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [enableBanking, setEnableBanking] = useState(null);
const [authSettings, setAuthSettings] = useState(null);
const [aiSettings, setAiSettings] = useState(null);
const [appSettings, setAppSettings] = useState(null);
const [categories, setCategories] = useState([]);
const [aspsps, setAspsps] = useState([]);
const [aiModels, setAiModels] = useState([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [loadingBanks, setLoadingBanks] = useState(false);
const [loadingModels, setLoadingModels] = useState(false);
const [error, setError] = useState(null);
const [deleteError, setDeleteError] = useState(null);
const [enableBankingError, setEnableBankingError] = useState(null);
const [authError, setAuthError] = useState(null);
const [aiError, setAiError] = useState(null);
const [appSettingsError, setAppSettingsError] = useState(null);
const [aiModelError, setAiModelError] = useState(null);
const [aiSaveMessage, setAiSaveMessage] = useState(null);
const [appSettingsMessage, setAppSettingsMessage] = useState(null);
const [aiSaveToken, setAiSaveToken] = useState(0);
const [testingNtfy, setTestingNtfy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
setAuthError(null);
setAiError(null);
setAppSettingsError(null);
setAiSaveMessage(null);
setAppSettingsMessage(null);
try {
const [bankingResult, categoriesResult, authResult, aiResult, appSettingsResult] = await Promise.allSettled([
api.getEnableBanking(),
api.getCategories(),
api.getAuthSettings(),
api.getAiSettings(),
api.getAppSettings(),
]);
if (bankingResult.status !== 'fulfilled' || categoriesResult.status !== 'fulfilled') {
throw new Error('core-settings-failed');
}
setEnableBanking(bankingResult.value);
setCategories(sortCategories(categoriesResult.value));
if (authResult.status === 'fulfilled') {
setAuthSettings(authResult.value);
} else {
setAuthSettings(null);
setAuthError(authResult.reason?.message || 'Pocket ID-inställningarna kunde inte laddas just nu.');
}
if (aiResult.status === 'fulfilled') {
setAiSettings(aiResult.value);
} else {
setAiSettings(null);
setAiError(aiResult.reason?.message || 'AI-inställningarna kunde inte laddas just nu.');
}
if (appSettingsResult.status === 'fulfilled') {
setAppSettings(appSettingsResult.value);
} else {
setAppSettings(null);
setAppSettingsError(appSettingsResult.reason?.message || 'Appinställningarna kunde inte laddas just nu.');
}
} catch {
setError('Kunde inte ladda inställningarna. Kontrollera att servern är igång.');
} finally {
setLoading(false);
}
}, []);
const refreshBanks = useCallback(async (country) => {
if (!enableBanking?.application_id || !enableBanking?.has_private_key) return;
setLoadingBanks(true);
setEnableBankingError(null);
try {
const result = await api.getEnableBankingAspsps(country || enableBanking.country || 'SE');
setAspsps(result.aspsps || []);
} catch (err) {
setEnableBankingError(err.message || 'Kunde inte hämta banker från Enable Banking.');
} finally {
setLoadingBanks(false);
}
}, [enableBanking]);
const refreshAiModels = useCallback(async (baseUrlOverride) => {
setLoadingModels(true);
setAiModelError(null);
try {
const result = await api.getAiModels(baseUrlOverride || aiSettings?.base_url || '');
setAiModels(result.models || []);
if ((result.models || []).length === 0) {
setAiModelError('Ollama svarade men inga modeller hittades. Kör till exempel `ollama pull llama3.1` eller en vision-modell.');
}
} catch (err) {
setAiModelError(err.message || 'Kunde inte läsa modeller från Ollama.');
} finally {
setLoadingModels(false);
}
}, [aiSettings?.base_url]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
function handleIntegrationUpdated(event) {
if (event.key && event.key !== 'enable-banking-updated-at') return;
load();
}
function handleFocus() {
if (localStorage.getItem('enable-banking-connect-pending') === '1') {
load();
}
}
window.addEventListener('storage', handleIntegrationUpdated);
window.addEventListener('focus', handleFocus);
return () => {
window.removeEventListener('storage', handleIntegrationUpdated);
window.removeEventListener('focus', handleFocus);
};
}, [load]);
useEffect(() => {
if (!enableBanking?.application_id || !enableBanking?.has_private_key) return;
refreshBanks(enableBanking.country || 'SE');
}, [enableBanking?.application_id, enableBanking?.has_private_key, enableBanking?.country, refreshBanks]);
useEffect(() => {
if (!aiSettings?.base_url) return;
refreshAiModels(aiSettings.base_url);
}, [aiSettings?.base_url, refreshAiModels]);
useEffect(() => {
const code = searchParams.get('code');
const state = searchParams.get('state');
if (!code || !state) return;
let active = true;
setBusy(true);
setEnableBankingError(null);
api.exchangeEnableBankingCode({ code, state })
.then(result => {
if (!active) return;
setEnableBanking(result);
localStorage.removeItem('enable-banking-connect-pending');
localStorage.setItem('enable-banking-updated-at', new Date().toISOString());
if (window.opener && !window.opener.closed) {
window.close();
return;
}
navigate('/installningar', { replace: true });
})
.catch(err => {
if (!active) return;
localStorage.removeItem('enable-banking-connect-pending');
setEnableBankingError(err.message || 'Kunde inte slutföra bankkopplingen.');
})
.finally(() => {
if (active) setBusy(false);
});
return () => {
active = false;
};
}, [navigate, searchParams]);
async function handleSaveEnableBanking(formData) {
setBusy(true);
setEnableBankingError(null);
try {
const saved = await api.updateEnableBanking(formData);
setEnableBanking(saved);
if (saved.application_id && saved.has_private_key) {
const result = await api.getEnableBankingAspsps(saved.country || formData.country || 'SE');
setAspsps(result.aspsps || []);
} else {
setAspsps([]);
}
} catch (err) {
setEnableBankingError(err.message || 'Kunde inte spara Enable Banking-inställningarna.');
} finally {
setBusy(false);
}
}
async function handleSaveAuthSettings(formData) {
setBusy(true);
setAuthError(null);
try {
const saved = await api.updateAuthSettings(formData);
setAuthSettings(saved);
} catch (err) {
setAuthError(err.message || 'Kunde inte spara Pocket ID-inställningarna.');
} finally {
setBusy(false);
}
}
async function handleSaveAiSettings(formData) {
setBusy(true);
setAiError(null);
setAiSaveMessage(null);
try {
await api.updateAiSettings(formData);
const persisted = await api.getAiSettings();
setAiSettings(persisted);
setAiSaveMessage('AI-inställningarna sparades.');
setAiSaveToken(Date.now());
} catch (err) {
setAiError(err.message || 'Kunde inte spara AI-inställningarna.');
} finally {
setBusy(false);
}
}
async function handleSaveAppSettings(formData) {
setBusy(true);
setAppSettingsError(null);
setAppSettingsMessage(null);
try {
const saved = await api.updateAppSettings(formData);
setAppSettings(saved);
setAppSettingsMessage('Appinställningarna sparades.');
} catch (err) {
setAppSettingsError(err.message || 'Kunde inte spara appinställningarna.');
} finally {
setBusy(false);
}
}
async function handleTestNtfy(formData) {
setTestingNtfy(true);
setAppSettingsError(null);
setAppSettingsMessage(null);
try {
await api.testNtfyNotifications(formData);
setAppSettingsMessage('Testnotisen skickades till ntfy.');
} catch (err) {
setAppSettingsError(err.message || 'Kunde inte skicka ntfy-testet.');
} finally {
setTestingNtfy(false);
}
}
async function handleConnectEnableBanking(connectData) {
setBusy(true);
setEnableBankingError(null);
try {
const saved = await api.updateEnableBanking({
enabled: connectData.enabled,
application_id: connectData.application_id,
private_key_pem: connectData.private_key_pem,
clear_private_key: connectData.clear_private_key,
redirect_url: connectData.redirect_url,
country: connectData.country,
psu_type: connectData.psu_type,
auto_sync_enabled: connectData.auto_sync_enabled,
sync_interval_minutes: connectData.sync_interval_minutes,
import_from_date: connectData.import_from_date,
incremental_sync_days: connectData.incremental_sync_days,
notes: connectData.notes || '',
});
setEnableBanking(saved);
const result = await api.connectEnableBanking(connectData);
localStorage.setItem('enable-banking-connect-pending', '1');
const opened = window.open(result.url, '_blank', 'noopener,noreferrer');
if (!opened) {
localStorage.removeItem('enable-banking-connect-pending');
setEnableBankingError('Webbläsaren blockerade den nya fliken. Tillåt popup-fönster eller öppna länken manuellt.');
}
} catch (err) {
localStorage.removeItem('enable-banking-connect-pending');
setEnableBankingError(err.message || 'Kunde inte starta bankkopplingen.');
} finally {
setBusy(false);
}
}
async function handleDisconnectEnableBanking() {
setBusy(true);
setEnableBankingError(null);
try {
const result = await api.disconnectEnableBanking();
setEnableBanking(result);
setAspsps([]);
} catch (err) {
setEnableBankingError(err.message || 'Kunde inte koppla från banken.');
} finally {
setBusy(false);
}
}
async function handleSyncEnableBanking() {
setBusy(true);
setEnableBankingError(null);
try {
const result = await api.syncEnableBanking();
setEnableBanking(result);
} catch (err) {
setEnableBankingError(err.message || 'Kunde inte synka banken.');
} finally {
setBusy(false);
}
}
async function handleUpdateEnableBankingAccount(accountId, alias) {
setBusy(true);
setEnableBankingError(null);
try {
const result = await api.updateEnableBankingAccount(accountId, { alias });
setEnableBanking(result);
} catch (err) {
setEnableBankingError(err.message || 'Kunde inte spara kontonamnet.');
} finally {
setBusy(false);
}
}
async function handleAddCategory(categoryData) {
const created = await api.addCategory(categoryData);
setDeleteError(null);
setCategories(items => sortCategories([...items, created]));
}
async function handleUpdateCategory(id, categoryData) {
const updated = await api.updateCategory(id, categoryData);
setDeleteError(null);
setCategories(items => sortCategories(items.map(item => item.id === id ? updated : item)));
}
async function handleDeleteCategory(id) {
try {
await api.deleteCategory(id);
setDeleteError(null);
setCategories(items => items.filter(item => item.id !== id));
} catch {
setDeleteError('Kategorin kan inte tas bort eftersom den används av räkningar, prenumerationer eller transaktioner.');
}
}
if (loading) {
return (
<div className="flex justify-center items-center py-20">
<Loader2 size={24} className="animate-spin text-blue-500" />
</div>
);
}
if (error) {
return (
<div className="mt-8 bg-red-50 border border-red-200 rounded-2xl px-5 py-4 text-red-700 text-sm">
{error}
</div>
);
}
return (
<div>
<section className="bg-slate-900 text-white rounded-3xl px-5 py-5 mb-4">
<div className="flex items-center gap-2 mb-2">
<Settings size={16} />
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-300">Inställningar</span>
</div>
<h1 className="text-xl font-semibold">Hantera appens grundinställningar</h1>
<p className="text-sm text-slate-300 mt-2">
Här samlar vi sådant som inte hör till en enskild månad: bankkoppling, AI, inloggning och kategorier.
</p>
</section>
<AISettingsSection
settings={aiSettings}
models={aiModels}
busy={busy}
loadingModels={loadingModels}
error={aiError}
modelError={aiModelError}
saveMessage={aiSaveMessage}
saveToken={aiSaveToken}
onSave={handleSaveAiSettings}
onRefreshModels={refreshAiModels}
/>
{appSettings && (
<AppSettingsSection
settings={appSettings}
accounts={enableBanking?.accounts || []}
busy={busy}
testingNtfy={testingNtfy}
error={appSettingsError}
message={appSettingsMessage}
onSave={handleSaveAppSettings}
onTestNtfy={handleTestNtfy}
/>
)}
{!appSettings && appSettingsError && (
<section className="bg-white rounded-2xl shadow-sm border border-red-200 overflow-hidden mb-3">
<div className="px-4 py-3 border-b border-red-100 bg-red-50 text-sm text-red-700">
Appinställningarna kunde inte laddas just nu.
</div>
<div className="px-4 py-4 text-sm text-slate-600">
{appSettingsError}
</div>
</section>
)}
<EnableBankingSection
integration={enableBanking}
aspsps={aspsps}
loadingBanks={loadingBanks}
busy={busy}
error={enableBankingError}
onRefreshBanks={refreshBanks}
onSave={handleSaveEnableBanking}
onConnect={handleConnectEnableBanking}
onDisconnect={handleDisconnectEnableBanking}
onSync={handleSyncEnableBanking}
onUpdateAccount={handleUpdateEnableBankingAccount}
/>
{authSettings && (
<OidcSettingsSection
settings={authSettings}
error={authError}
busy={busy}
onSave={handleSaveAuthSettings}
/>
)}
{!authSettings && authError && (
<section className="bg-white rounded-2xl shadow-sm border border-red-200 overflow-hidden mb-3">
<div className="px-4 py-3 border-b border-red-100 bg-red-50 text-sm text-red-700">
Pocket ID / OIDC kunde inte laddas just nu.
</div>
<div className="px-4 py-4 text-sm text-slate-600">
{authError}
</div>
</section>
)}
<CategorySettingsSection
categories={categories}
onAdd={handleAddCategory}
onUpdate={handleUpdateCategory}
onDelete={handleDeleteCategory}
deleteError={deleteError}
/>
</div>
);
}
+1288
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
// Parses Swedish number format: "430 635,10" or "430635.10" → 430635.10
export function parseAmount(str) {
if (str === '' || str == null) return NaN;
return parseFloat(String(str).replace(/\s/g, '').replace(',', '.'));
}
export const MONTHS = [
'Januari','Februari','Mars','April','Maj','Juni',
'Juli','Augusti','September','Oktober','November','December',
];
export function fmt(amount) {
if (amount == null) return '';
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
export function fmtShort(amount) {
return fmt(amount).replace(' kr', ' kr');
}
export function monthLabel(year, month) {
return `${MONTHS[month - 1]} ${year}`;
}
export function today() {
return new Date().toISOString().split('T')[0];
}
export function estimatedPayoff(balance, monthlyPayment, interestRate = 0) {
if (!monthlyPayment || monthlyPayment <= 0) return null;
if (balance <= 0) return 'Betalt';
const monthly = interestRate / 12 / 100;
let months = 0;
let b = balance;
if (monthly <= 0) {
months = Math.ceil(b / monthlyPayment);
} else {
while (b > 0 && months < 600) {
b = b * (1 + monthly) - monthlyPayment;
months++;
}
if (b > 0) return 'Aldrig (betalning för låg)';
}
const d = new Date();
d.setMonth(d.getMonth() + months);
return d.toLocaleDateString('sv-SE', { year: 'numeric', month: 'long' });
}
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: {
sans: ['"Inter"', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};
+54
View File
@@ -0,0 +1,54 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icon.svg', 'icon-192.png', 'icon-512.png'],
manifest: {
id: '/',
name: 'Enkelbudget',
short_name: 'Budget',
description: 'Enkel månadsbudget och lånehanterare',
start_url: '/',
scope: '/',
display: 'standalone',
display_override: ['window-controls-overlay', 'standalone', 'minimal-ui'],
orientation: 'portrait',
background_color: '#F8FAFC',
theme_color: '#2563EB',
lang: 'sv',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
navigateFallbackDenylist: [
/^\/api\//,
/^\/auth\//,
/^\/enablebanking\//,
],
runtimeCaching: [
{
urlPattern: /^\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 86400 },
},
},
],
},
}),
],
server: {
proxy: {
'/api': 'http://localhost:7843',
},
},
});
+31
View File
@@ -0,0 +1,31 @@
services:
app:
build: .
ports:
- "7842:7842"
volumes:
- data:/app/data
- /mnt/user/noah/images/icons:/icons
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=7842
- DB_PATH=/app/data/budget.json
- APP_BASE_URL=${APP_BASE_URL:-}
- AUTH_SESSION_SECRET=${AUTH_SESSION_SECRET:-}
- SETUP_TOKEN=${SETUP_TOKEN:-}
- OIDC_ISSUER=${OIDC_ISSUER:-}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_SCOPE=${OIDC_SCOPE:-openid profile email groups}
- OIDC_ALLOWED_GROUPS=${OIDC_ALLOWED_GROUPS:-}
- OIDC_ALLOWED_EMAILS=${OIDC_ALLOWED_EMAILS:-}
- OIDC_ALLOWED_DOMAINS=${OIDC_ALLOWED_DOMAINS:-}
labels:
net.unraid.docker.webui: "http://[IP]:[PORT:7842]"
net.unraid.docker.icon: "/mnt/user/noah/images/icons/enkelbudget.png"
net.unraid.docker.managed: "true"
volumes:
data:
Executable
+156
View File
@@ -0,0 +1,156 @@
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
const DB_PATH = process.env.DB_PATH || './data/budget.json';
const DEFAULT = {
categories: [
{ id: 1, name: 'Kivra', sort_order: 1, color: '#7C3AED' },
{ id: 2, name: 'eFaktura', sort_order: 2, color: '#2563EB' },
{ id: 3, name: 'Manuella', sort_order: 3, color: '#D97706' },
{ id: 4, name: 'Autogiro', sort_order: 4, color: '#059669' },
],
months: [],
income: [],
bills: [],
subscriptions: [],
loans: [],
payments: [],
banking: {
enable_banking: {
enabled: false,
status: 'not_connected',
institution: '',
last_sync_at: null,
notes: '',
application_id: '',
private_key_pem: '',
redirect_url: '',
country: 'SE',
psu_type: 'personal',
auto_sync_enabled: false,
sync_interval_minutes: 360,
import_from_date: null,
incremental_sync_days: 7,
pending_state: null,
last_callback_at: null,
last_callback_url: '',
last_callback_code_present: false,
last_callback_state: '',
last_callback_exchange_at: null,
last_callback_exchange_error: '',
session_id: null,
session_expires_at: null,
session_created_at: null,
account_aliases: {},
transaction_categories: {},
accounts: [],
transactions: [],
last_sync_summary: null,
last_error: null,
},
},
auth: {
settings: {
enabled: false,
issuer: '',
discovery_url: '',
client_id: '',
client_secret: '',
redirect_uri: '',
scope: 'openid profile email groups',
session_secret: '',
allowed_groups: '',
allowed_emails: '',
allowed_domains: '',
session_ttl_hours: 12,
},
sessions: [],
pending: [],
},
ai: {
ollama: {
enabled: false,
base_url: 'http://host.docker.internal:11434',
model: '',
vision_model: '',
system_prompt: '',
include_budget_context: true,
include_banking_context: true,
},
conversations: [],
},
app_settings: {
finance_profile: {
salary_day_of_month: 25,
buffer_days_target: 7,
salary_account_uid: '',
recurring_income_note: '',
},
account_view: {
primary_account_uid: '',
include_savings_in_ai: false,
hide_zero_balance_accounts: false,
},
notifications: {
ntfy_enabled: false,
ntfy_base_url: 'https://ntfy.sh',
ntfy_topic: '',
ntfy_access_token: '',
ntfy_title: 'Enkelbudget',
ntfy_tags: 'money_with_wings,bank',
ntfy_click_url: '',
ntfy_priority: 3,
notify_new_transactions: true,
include_pending_transactions: false,
minimum_transaction_amount: 0,
last_error: '',
last_sent_at: null,
},
},
_seq: { categories: 4, months: 0, income: 0, bills: 0, subscriptions: 0, loans: 0, payments: 0 },
};
let _db = null;
export async function getDb() {
if (!_db) {
mkdirSync(dirname(DB_PATH), { recursive: true });
const adapter = new JSONFile(DB_PATH);
_db = new Low(adapter, DEFAULT);
await _db.read();
_db.data ||= structuredClone(DEFAULT);
_db.data.categories ||= structuredClone(DEFAULT.categories);
_db.data.months ||= [];
_db.data.income ||= [];
_db.data.bills ||= [];
_db.data.subscriptions ||= [];
_db.data.loans ||= [];
_db.data.payments ||= [];
_db.data.banking ||= structuredClone(DEFAULT.banking);
_db.data.banking.enable_banking ||= structuredClone(DEFAULT.banking.enable_banking);
_db.data.auth ||= structuredClone(DEFAULT.auth);
_db.data.auth.settings ||= structuredClone(DEFAULT.auth.settings);
_db.data.auth.sessions ||= [];
_db.data.auth.pending ||= [];
_db.data.ai ||= structuredClone(DEFAULT.ai);
_db.data.ai.ollama ||= structuredClone(DEFAULT.ai.ollama);
_db.data.ai.conversations ||= [];
_db.data.app_settings ||= structuredClone(DEFAULT.app_settings);
_db.data.app_settings.finance_profile ||= structuredClone(DEFAULT.app_settings.finance_profile);
_db.data.app_settings.account_view ||= structuredClone(DEFAULT.app_settings.account_view);
_db.data.app_settings.notifications ||= structuredClone(DEFAULT.app_settings.notifications);
_db.data._seq ||= {};
for (const [key, value] of Object.entries(DEFAULT._seq)) {
_db.data._seq[key] ??= value;
}
}
return _db;
}
export function nextId(db, table) {
db.data._seq[table] = (db.data._seq[table] ?? 0) + 1;
return db.data._seq[table];
}
+183
View File
@@ -0,0 +1,183 @@
import crypto from 'crypto';
const API_BASE = 'https://api.enablebanking.com';
function base64url(input) {
return Buffer.from(input)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
export function publicEnableBankingConfig(config) {
return {
...config,
private_key_pem: undefined,
has_private_key: Boolean(config.private_key_pem?.trim()),
transaction_categories: config.transaction_categories || {},
accounts: (config.accounts || []).map(account => ({
...account,
display_name: account.alias || account.name || 'Konto',
})),
};
}
export function createEnableBankingJwt({ application_id, private_key_pem, expiresInSeconds = 3600 }) {
if (!application_id?.trim()) throw new Error('Enable Banking application_id saknas');
if (!private_key_pem?.trim()) throw new Error('Enable Banking private key saknas');
const iat = Math.floor(Date.now() / 1000);
const header = {
typ: 'JWT',
alg: 'RS256',
kid: application_id.trim(),
};
const payload = {
iss: 'enablebanking.com',
aud: 'api.enablebanking.com',
iat,
exp: iat + expiresInSeconds,
};
const unsignedToken = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
const signer = crypto.createSign('RSA-SHA256');
signer.update(unsignedToken);
signer.end();
const signature = signer.sign(private_key_pem.trim());
return `${unsignedToken}.${base64url(signature)}`;
}
export async function enableBankingRequest(config, path, { method = 'GET', body } = {}) {
const token = createEnableBankingJwt(config);
const response = await fetch(`${API_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!response.ok) {
const message = typeof data === 'object' && data?.message
? data.message
: typeof data === 'object' && data?.error
? data.error
: text || `Enable Banking API error ${response.status}`;
const error = new Error(`${message} [${method} ${path}]`);
error.status = response.status;
error.details = data;
error.path = path;
error.method = method;
throw error;
}
return data;
}
export async function fetchAspsps(config, country) {
return enableBankingRequest(config, `/aspsps?country=${encodeURIComponent(country)}`);
}
export async function startAuthorization(config, body) {
return enableBankingRequest(config, '/auth', {
method: 'POST',
body,
});
}
export async function createSession(config, code) {
return enableBankingRequest(config, '/sessions', {
method: 'POST',
body: { code },
});
}
export async function getSession(config, sessionId) {
return enableBankingRequest(config, `/sessions/${sessionId}`);
}
export async function deleteSession(config, sessionId) {
return enableBankingRequest(config, `/sessions/${sessionId}`, { method: 'DELETE' });
}
export async function getAccountDetails(config, accountId) {
return enableBankingRequest(config, `/accounts/${accountId}/details`);
}
export async function getAccountBalances(config, accountId) {
return enableBankingRequest(config, `/accounts/${accountId}/balances`);
}
export async function getAllTransactions(config, accountId, options = {}) {
const allTransactions = [];
let continuationKey = null;
do {
const variants = [
{ includeDateFrom: true, includeDateTo: true, includeStrategy: true },
{ includeDateFrom: true, includeDateTo: false, includeStrategy: true },
{ includeDateFrom: true, includeDateTo: false, includeStrategy: false },
{ includeDateFrom: false, includeDateTo: false, includeStrategy: false },
];
let page = null;
let lastError = null;
for (const variant of variants) {
const params = new URLSearchParams();
if (variant.includeDateFrom && options.date_from) params.set('date_from', options.date_from);
if (variant.includeDateTo && options.date_to) params.set('date_to', options.date_to);
if (continuationKey) params.set('continuation_key', continuationKey);
if (variant.includeStrategy) params.set('strategy', 'all');
const query = params.toString();
try {
page = await enableBankingRequest(
config,
`/accounts/${accountId}/transactions${query ? `?${query}` : ''}`
);
break;
} catch (error) {
lastError = error;
const badParams = (error.status === 400 || error.status === 422)
&& String(error.message || '').toLowerCase().includes('wrong request parameters provided');
if (!badParams) {
throw error;
}
}
}
if (!page) {
throw lastError || new Error('Kunde inte hämta transaktioner');
}
const transactionGroups = Array.isArray(page?.transactions)
? page.transactions
: [
...((page?.transactions?.booked ?? []).map(transaction => ({ ...transaction, status: transaction.status || 'booked' }))),
...((page?.transactions?.pending ?? []).map(transaction => ({ ...transaction, status: transaction.status || 'pending' }))),
...((page?.booked ?? []).map(transaction => ({ ...transaction, status: transaction.status || 'booked' }))),
...((page?.pending ?? []).map(transaction => ({ ...transaction, status: transaction.status || 'pending' }))),
];
allTransactions.push(...transactionGroups);
continuationKey = page?.continuation_key || null;
} while (continuationKey);
return allTransactions;
}
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
if [ -d /icons ]; then
cp /app/public/icon-512.png /icons/enkelbudget.png
fi
exec node server.js
Executable
+649
View File
@@ -0,0 +1,649 @@
import crypto from 'crypto';
import { getDb } from './db.js';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { assertSafeUrl } from './ssrf.js';
const SESSION_COOKIE = 'enkelbudget_session';
const TRANSACTION_COOKIE = 'enkelbudget_auth_tx';
const discoveryCache = new Map();
const jwksCache = new Map();
function getJwks(jwksUri) {
if (!jwksCache.has(jwksUri)) {
jwksCache.set(jwksUri, createRemoteJWKSet(new URL(jwksUri)));
}
return jwksCache.get(jwksUri);
}
function base64url(input) {
return Buffer.from(input)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function sha256base64url(input) {
return crypto.createHash('sha256').update(input).digest('base64url');
}
function parseList(value) {
return String(value || '')
.split(',')
.map(item => item.trim())
.filter(Boolean);
}
function parseCookies(header) {
const cookies = {};
for (const part of String(header || '').split(';')) {
const index = part.indexOf('=');
if (index < 0) continue;
const name = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
cookies[name] = decodeURIComponent(value);
}
return cookies;
}
function signValue(value, secret) {
return crypto.createHmac('sha256', secret).update(value).digest('base64url');
}
function encodeSignedValue(value, secret) {
return `${value}.${signValue(value, secret)}`;
}
function decodeSignedValue(raw, secret) {
if (!raw) return null;
const index = raw.lastIndexOf('.');
if (index < 0) return null;
const value = raw.slice(0, index);
const signature = raw.slice(index + 1);
const expected = signValue(value, secret);
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (signatureBuffer.length !== expectedBuffer.length) return null;
if (!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) return null;
return value;
}
function serializeCookie(name, value, options = {}) {
const parts = [`${name}=${encodeURIComponent(value)}`];
if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`);
if (options.httpOnly !== false) parts.push('HttpOnly');
parts.push(`Path=${options.path || '/'}`);
parts.push(`SameSite=${options.sameSite || 'Lax'}`);
if (options.secure) parts.push('Secure');
return parts.join('; ');
}
function normalizeIssuer(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
function normalizeAbsoluteUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
if (raw.startsWith('ttps://')) return `h${raw}`;
if (raw.startsWith('tps://')) return `ht${raw}`;
return raw;
}
function getEnvAuthDefaults() {
const issuer = normalizeIssuer(process.env.OIDC_ISSUER);
const discoveryUrl = String(process.env.OIDC_DISCOVERY_URL || '').trim()
|| (issuer ? `${issuer}/.well-known/openid-configuration` : '');
const clientId = String(process.env.OIDC_CLIENT_ID || '').trim();
const clientSecret = String(process.env.OIDC_CLIENT_SECRET || '').trim();
const appBaseUrl = normalizeIssuer(process.env.APP_BASE_URL);
const redirectUri = normalizeAbsoluteUrl(process.env.OIDC_REDIRECT_URI)
|| (appBaseUrl ? `${appBaseUrl}/auth/callback` : '');
const sessionSecret = String(process.env.AUTH_SESSION_SECRET || process.env.SESSION_SECRET || clientSecret).trim();
return {
enabled: Boolean(issuer && clientId && clientSecret && redirectUri && sessionSecret),
issuer,
discovery_url: discoveryUrl,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
scope: String(process.env.OIDC_SCOPE || 'openid profile email groups').trim(),
session_secret: sessionSecret,
session_ttl_hours: Math.max(1, parseInt(process.env.AUTH_SESSION_TTL_HOURS || '12', 10) || 12),
allowed_groups: String(process.env.OIDC_ALLOWED_GROUPS || '').trim(),
allowed_emails: String(process.env.OIDC_ALLOWED_EMAILS || '').trim(),
allowed_domains: String(process.env.OIDC_ALLOWED_DOMAINS || '').trim(),
};
}
function normalizeStoredSettings(input = {}, fallback = {}) {
const issuer = normalizeIssuer(input.issuer ?? fallback.issuer);
const discoveryUrl = String((input.discovery_url ?? fallback.discovery_url) || '').trim()
|| (issuer ? `${issuer}/.well-known/openid-configuration` : '');
const clientId = String((input.client_id ?? fallback.client_id) || '').trim();
const clientSecret = String((input.client_secret ?? fallback.client_secret) || '').trim();
const redirectUri = normalizeAbsoluteUrl((input.redirect_uri ?? fallback.redirect_uri) || '');
const scope = String((input.scope ?? fallback.scope) || 'openid profile email groups').trim() || 'openid profile email groups';
const sessionSecret = String((input.session_secret ?? fallback.session_secret) || '').trim() || clientSecret;
const allowedGroups = String((input.allowed_groups ?? fallback.allowed_groups) || '').trim();
const allowedEmails = String((input.allowed_emails ?? fallback.allowed_emails) || '').trim();
const allowedDomains = String((input.allowed_domains ?? fallback.allowed_domains) || '').trim();
const sessionTtlHours = Math.max(1, parseInt(input.session_ttl_hours ?? fallback.session_ttl_hours ?? 12, 10) || 12);
const explicitEnabled = input.enabled !== undefined ? Boolean(input.enabled) : Boolean(fallback.enabled);
return {
enabled: explicitEnabled && Boolean(issuer && clientId && clientSecret && redirectUri && sessionSecret),
issuer,
discovery_url: discoveryUrl,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
scope,
session_secret: sessionSecret,
session_ttl_hours: sessionTtlHours,
allowed_groups: allowedGroups,
allowed_emails: allowedEmails,
allowed_domains: allowedDomains,
};
}
export async function getAuthSettings() {
const db = await getDb();
db.data.auth ||= { settings: {}, sessions: [], pending: [] };
db.data.auth.settings ||= {};
db.data.auth.sessions ||= [];
db.data.auth.pending ||= [];
const fallback = getEnvAuthDefaults();
const settings = normalizeStoredSettings(db.data.auth.settings, fallback);
db.data.auth.settings = {
...db.data.auth.settings,
enabled: settings.enabled,
issuer: settings.issuer,
discovery_url: settings.discovery_url,
client_id: settings.client_id,
client_secret: settings.client_secret,
redirect_uri: settings.redirect_uri,
scope: settings.scope,
session_secret: settings.session_secret,
allowed_groups: settings.allowed_groups,
allowed_emails: settings.allowed_emails,
allowed_domains: settings.allowed_domains,
session_ttl_hours: settings.session_ttl_hours,
};
return settings;
}
export async function getPublicAuthSettings() {
const settings = await getAuthSettings();
return {
enabled: settings.enabled,
issuer: settings.issuer,
discovery_url: settings.discovery_url,
client_id: settings.client_id,
redirect_uri: settings.redirect_uri,
scope: settings.scope,
session_ttl_hours: settings.session_ttl_hours,
allowed_groups: settings.allowed_groups,
allowed_emails: settings.allowed_emails,
allowed_domains: settings.allowed_domains,
has_client_secret: Boolean(settings.client_secret),
has_session_secret: Boolean(settings.session_secret),
is_valid: Boolean(settings.issuer && settings.client_id && settings.client_secret && settings.redirect_uri && settings.session_secret),
};
}
export async function updateAuthSettings(input) {
const db = await getDb();
db.data.auth ||= { settings: {}, sessions: [], pending: [] };
db.data.auth.settings ||= {};
db.data.auth.sessions ||= [];
db.data.auth.pending ||= [];
const current = normalizeStoredSettings(db.data.auth.settings, getEnvAuthDefaults());
const next = normalizeStoredSettings({
...current,
enabled: input.enabled,
issuer: input.issuer,
discovery_url: input.discovery_url,
client_id: input.client_id,
client_secret: typeof input.client_secret === 'string' && input.client_secret.trim() ? input.client_secret.trim() : current.client_secret,
redirect_uri: input.redirect_uri,
scope: input.scope,
session_secret: typeof input.session_secret === 'string' && input.session_secret.trim() ? input.session_secret.trim() : current.session_secret,
allowed_groups: input.allowed_groups,
allowed_emails: input.allowed_emails,
allowed_domains: input.allowed_domains,
session_ttl_hours: input.session_ttl_hours,
});
if (input.clear_client_secret) next.client_secret = '';
if (input.clear_session_secret) next.session_secret = '';
next.enabled = Boolean(input.enabled) && Boolean(next.issuer && next.client_id && next.client_secret && next.redirect_uri && next.session_secret);
db.data.auth.settings = { ...next };
await db.write();
return getPublicAuthSettings();
}
async function getEffectiveAuthConfig() {
const settings = await getAuthSettings();
return {
enabled: settings.enabled,
issuer: settings.issuer,
discoveryUrl: settings.discovery_url,
clientId: settings.client_id,
clientSecret: settings.client_secret,
redirectUri: settings.redirect_uri,
scope: settings.scope,
sessionSecret: settings.session_secret,
sessionTtlHours: settings.session_ttl_hours,
allowedGroups: parseList(settings.allowed_groups),
allowedEmails: parseList(settings.allowed_emails).map(item => item.toLowerCase()),
allowedDomains: parseList(settings.allowed_domains).map(item => item.toLowerCase()),
};
}
async function getDiscovery(config) {
const cacheKey = config.discoveryUrl;
const cached = discoveryCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < 60 * 60 * 1000) {
return cached.value;
}
await assertSafeUrl(config.discoveryUrl);
const response = await fetch(config.discoveryUrl);
if (!response.ok) {
throw new Error(`Kunde inte läsa OIDC discovery (${response.status})`);
}
const data = await response.json();
discoveryCache.set(cacheKey, { value: data, fetchedAt: Date.now() });
return data;
}
function normalizeGroups(value) {
if (Array.isArray(value)) return value.map(item => String(item));
if (typeof value === 'string') return value.split(',').map(item => item.trim()).filter(Boolean);
return [];
}
function buildUserProfile(idTokenClaims, userInfo) {
const source = { ...idTokenClaims, ...userInfo };
return {
sub: source.sub,
name: source.name || source.preferred_username || source.email || source.sub,
email: source.email || null,
preferred_username: source.preferred_username || null,
groups: normalizeGroups(source.groups),
};
}
function ensureAuthorizedUser(config, user) {
if (config.allowedEmails.length && !config.allowedEmails.includes(String(user.email || '').toLowerCase())) {
throw new Error('Det här kontot är inte tillåtet för Enkelbudget.');
}
if (config.allowedDomains.length) {
const domain = String(user.email || '').split('@')[1]?.toLowerCase() || '';
if (!config.allowedDomains.includes(domain)) {
throw new Error('E-postdomänen är inte tillåten för Enkelbudget.');
}
}
if (config.allowedGroups.length) {
const groups = (user.groups || []).map(group => group.toLowerCase());
const hasMatch = config.allowedGroups.some(group => groups.includes(group.toLowerCase()));
if (!hasMatch) {
throw new Error('Kontot saknar rätt Pocket ID-grupp för att logga in.');
}
}
}
async function cleanupAuthState(db) {
const now = Date.now();
const sessions = db.data.auth.sessions || [];
db.data.auth.sessions = sessions.filter(session => !session.expires_at || new Date(session.expires_at).getTime() > now);
const pending = db.data.auth.pending || [];
db.data.auth.pending = pending.filter(transaction => now - new Date(transaction.created_at).getTime() < 15 * 60 * 1000);
}
async function saveDb(db) {
await cleanupAuthState(db);
await db.write();
}
function getSafeReturnTo(value) {
if (!value || typeof value !== 'string') return '/';
if (!value.startsWith('/')) return '/';
if (value.startsWith('//')) return '/';
if (value.startsWith('/api/')) return '/';
if (value.startsWith('/auth/')) return '/';
if (value.startsWith('/login')) return '/';
if (value.includes('returnTo=')) return '/';
return value;
}
async function createPendingTransaction(returnTo) {
const config = await getEffectiveAuthConfig();
const discovery = await getDiscovery(config);
const db = await getDb();
db.data.auth ||= { settings: {}, sessions: [], pending: [] };
db.data.auth.settings ||= {};
db.data.auth.sessions ||= [];
db.data.auth.pending ||= [];
const state = crypto.randomUUID();
const nonce = crypto.randomUUID();
const codeVerifier = base64url(crypto.randomBytes(48));
const transactionId = crypto.randomUUID();
db.data.auth.pending.push({
id: transactionId,
state,
nonce,
code_verifier: codeVerifier,
return_to: getSafeReturnTo(returnTo),
created_at: new Date().toISOString(),
});
await saveDb(db);
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
response_type: 'code',
scope: config.scope,
state,
nonce,
code_challenge: sha256base64url(codeVerifier),
code_challenge_method: 'S256',
});
return {
config,
transactionId,
authUrl: `${discovery.authorization_endpoint}?${params.toString()}`,
};
}
async function exchangeCodeForSession(code, transaction) {
const config = await getEffectiveAuthConfig();
const discovery = await getDiscovery(config);
const tokenResponse = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
code_verifier: transaction.code_verifier,
}),
});
if (!tokenResponse.ok) {
const details = await tokenResponse.text();
throw new Error(`Pocket ID nekade tokenutbytet (${tokenResponse.status}). ${details}`.trim());
}
const tokenData = await tokenResponse.json();
if (!tokenData.id_token) {
throw new Error('Pocket ID returnerade inget id_token.');
}
if (!discovery.jwks_uri) {
throw new Error('OIDC discovery saknar jwks_uri kan inte verifiera id_token.');
}
// Verifierar signatur, iss, aud och exp kryptografiskt mot providerns JWKS.
let idTokenClaims;
try {
const jwks = getJwks(discovery.jwks_uri);
const verified = await jwtVerify(tokenData.id_token, jwks, {
issuer: normalizeIssuer(discovery.issuer || config.issuer),
audience: config.clientId,
});
idTokenClaims = verified.payload;
} catch (error) {
throw new Error(`Kunde inte verifiera id_token från Pocket ID: ${error.message}`);
}
if (idTokenClaims.nonce && idTokenClaims.nonce !== transaction.nonce) {
throw new Error('Pocket ID returnerade ett id_token med fel nonce.');
}
let userInfo = {};
if (discovery.userinfo_endpoint && tokenData.access_token) {
const userInfoResponse = await fetch(discovery.userinfo_endpoint, {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!userInfoResponse.ok) {
const details = await userInfoResponse.text();
throw new Error(`Kunde inte läsa användarprofil från Pocket ID (${userInfoResponse.status}). ${details}`.trim());
}
userInfo = await userInfoResponse.json();
// Skydd mot att userinfo tillhör en annan användare än id_token.
if (userInfo.sub && idTokenClaims.sub && userInfo.sub !== idTokenClaims.sub) {
throw new Error('userinfo-svaret matchar inte id_token (sub skiljer sig).');
}
}
const user = buildUserProfile(idTokenClaims, userInfo);
ensureAuthorizedUser(config, user);
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + config.sessionTtlHours * 60 * 60 * 1000).toISOString();
const db = await getDb();
db.data.auth ||= { settings: {}, sessions: [], pending: [] };
db.data.auth.sessions.push({
id: sessionId,
user,
created_at: new Date().toISOString(),
expires_at: expiresAt,
});
db.data.auth.pending = (db.data.auth.pending || []).filter(item => item.id !== transaction.id);
await saveDb(db);
return {
config,
id: sessionId,
user,
expires_at: expiresAt,
return_to: getSafeReturnTo(transaction.return_to),
};
}
export async function getAuthSession(req) {
const config = await getEffectiveAuthConfig();
if (!config.enabled) {
return { enabled: false, authenticated: false, user: null };
}
const cookies = parseCookies(req.headers.cookie);
const signedValue = decodeSignedValue(cookies[SESSION_COOKIE], config.sessionSecret);
if (!signedValue) {
return { enabled: true, authenticated: false, user: null };
}
const db = await getDb();
db.data.auth ||= { settings: {}, sessions: [], pending: [] };
db.data.auth.settings ||= {};
db.data.auth.sessions ||= [];
db.data.auth.pending ||= [];
await cleanupAuthState(db);
const session = db.data.auth.sessions.find(item => item.id === signedValue);
if (!session) {
return { enabled: true, authenticated: false, user: null };
}
return {
enabled: true,
authenticated: true,
user: session.user,
expires_at: session.expires_at,
};
}
export async function handleOidcLogin(req, res) {
const { config, transactionId, authUrl } = await createPendingTransaction(req.query.returnTo);
if (!config.enabled) {
res.redirect('/');
return;
}
res.setHeader('Set-Cookie', serializeCookie(
TRANSACTION_COOKIE,
encodeSignedValue(transactionId, config.sessionSecret),
{ maxAge: 15 * 60, secure: req.secure || req.headers['x-forwarded-proto'] === 'https' }
));
res.redirect(authUrl);
}
export async function handleOidcCallback(req, res) {
const config = await getEffectiveAuthConfig();
if (!config.enabled) {
res.redirect('/');
return;
}
const cookies = parseCookies(req.headers.cookie);
const transactionId = decodeSignedValue(cookies[TRANSACTION_COOKIE], config.sessionSecret);
const db = await getDb();
db.data.auth ||= { settings: {}, sessions: [], pending: [] };
db.data.auth.settings ||= {};
db.data.auth.sessions ||= [];
db.data.auth.pending ||= [];
const transaction = (db.data.auth.pending || []).find(item => item.id === transactionId);
if (!transaction || transaction.state !== req.query.state) {
res.status(400).send('Ogiltig eller utgången Pocket ID-inloggning.');
return;
}
if (!req.query.code) {
res.status(400).send('Pocket ID returnerade ingen auth code.');
return;
}
try {
const session = await exchangeCodeForSession(String(req.query.code), transaction);
res.setHeader('Set-Cookie', [
serializeCookie(
SESSION_COOKIE,
encodeSignedValue(session.id, session.config.sessionSecret),
{
maxAge: session.config.sessionTtlHours * 60 * 60,
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
}
),
serializeCookie(
TRANSACTION_COOKIE,
'',
{
maxAge: 0,
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
}
),
]);
res.redirect(session.return_to || '/');
} catch (error) {
db.data.auth.pending = (db.data.auth.pending || []).filter(item => item.id !== transaction.id);
await saveDb(db);
res.status(500).send(`Pocket ID-inloggningen misslyckades: ${error.message}`);
}
}
export async function handleOidcLogout(req, res) {
const config = await getEffectiveAuthConfig();
if (config.enabled) {
const cookies = parseCookies(req.headers.cookie);
const sessionId = decodeSignedValue(cookies[SESSION_COOKIE], config.sessionSecret);
if (sessionId) {
const db = await getDb();
db.data.auth ||= { settings: {}, sessions: [], pending: [] };
db.data.auth.settings ||= {};
db.data.auth.sessions ||= [];
db.data.auth.pending ||= [];
db.data.auth.sessions = (db.data.auth.sessions || []).filter(item => item.id !== sessionId);
await saveDb(db);
}
}
res.setHeader('Set-Cookie', serializeCookie(
SESSION_COOKIE,
'',
{ maxAge: 0, secure: req.secure || req.headers['x-forwarded-proto'] === 'https' }
));
res.json({ ok: true });
}
export async function renderLoginPage(req, res) {
const config = await getEffectiveAuthConfig();
if (!config.enabled) {
res.redirect('/');
return;
}
const returnTo = encodeURIComponent(getSafeReturnTo(String(req.query.returnTo || '/')));
res.type('html').send(`<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Logga in - Enkelbudget</title>
<style>
:root { color-scheme: light; }
body { margin: 0; font-family: system-ui, sans-serif; background: linear-gradient(160deg, #eff6ff, #ffffff 45%, #ecfeff); color: #0f172a; }
main { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.card { width: min(460px, 100%); background: rgba(255,255,255,.94); border: 1px solid #dbeafe; border-radius: 24px; box-shadow: 0 24px 80px rgba(15, 23, 42, .12); padding: 28px; }
.eyebrow { font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: #2563eb; }
h1 { margin: 10px 0 12px; font-size: 30px; line-height: 1.1; }
p { margin: 0 0 22px; color: #475569; line-height: 1.55; }
.actions { display: flex; justify-content: center; margin-top: 8px; }
a { display: inline-flex; align-items: center; justify-content: center; min-width: 240px; max-width: 100%; border-radius: 14px; background: #2563eb; color: white; text-decoration: none; font-weight: 700; padding: 14px 22px; box-shadow: 0 10px 24px rgba(37, 99, 235, .18); }
.hint { margin-top: 14px; font-size: 13px; color: #64748b; text-align: center; }
</style>
</head>
<body>
<main>
<section class="card">
<div class="eyebrow">Pocket ID</div>
<h1>Logga in i Enkelbudget</h1>
<p>Appen är skyddad med OIDC/SSO via Pocket ID. Fortsätt med ditt godkända konto för att komma åt budget, lån, transaktioner och inställningar.</p>
<div class="actions">
<a href="/auth/login?returnTo=${returnTo}">Fortsätt med Pocket ID</a>
</div>
<div class="hint">Om du inte ska vara här: stäng fliken eller kontrollera grupp- och klientinställningarna i Pocket ID.</div>
</section>
</main>
</body>
</html>`);
}
export async function requireApiAuth(req, res, next) {
try {
const session = await getAuthSession(req);
req.auth = session;
if (!session.enabled || session.authenticated) {
next();
return;
}
res.status(401).json({
error: 'Autentisering krävs',
login_url: `/login?returnTo=${encodeURIComponent(req.originalUrl === '/api/auth/session' ? '/' : req.originalUrl.replace(/^\/api/, '') || '/')}`,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
}
+1075
View File
File diff suppressed because it is too large Load Diff
Generated Executable
+892
View File
@@ -0,0 +1,892 @@
{
"name": "enkelbudget-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "enkelbudget-server",
"version": "1.0.0",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"jose": "^6.2.3",
"lowdb": "^7.0.1"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/lowdb": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz",
"integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==",
"license": "MIT",
"dependencies": {
"steno": "^4.0.2"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/steno": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz",
"integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "enkelbudget-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.1",
"jose": "^6.2.3",
"lowdb": "^7.0.1"
}
}
+1872
View File
File diff suppressed because it is too large Load Diff
Executable
+80
View File
@@ -0,0 +1,80 @@
import dns from 'node:dns/promises';
import net from 'node:net';
// Värdnamn som medvetet tillåts trots att de pekar lokalt (t.ex. lokal Ollama).
export const LOCAL_ALLOWED_HOSTS = new Set([
'host.docker.internal',
'localhost',
'127.0.0.1',
'::1',
]);
function isPrivateIp(ip) {
if (net.isIPv4(ip)) {
const [a, b] = ip.split('.').map(Number);
return (
a === 10 ||
a === 127 ||
a === 0 ||
(a === 192 && b === 168) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 169 && b === 254) // link-local / cloud-metadata
);
}
if (net.isIPv6(ip)) {
const lower = ip.toLowerCase();
return (
lower === '::1' ||
lower === '::' ||
lower.startsWith('fc') ||
lower.startsWith('fd') ||
lower.startsWith('fe80') ||
lower.startsWith('::ffff:') // IPv4-mappad
);
}
return true; // okänt format → blockera
}
/**
* Validerar en URL mot SSRF. Kastar om adressen är ogiltig eller pekar internt.
* @param {string} rawUrl
* @param {{ allowList?: Set<string>, allowPrivateNetwork?: boolean }} [options] - värdnamn som tillåts trots privat IP
* @returns {Promise<URL>}
*/
export async function assertSafeUrl(rawUrl, { allowList = new Set(), allowPrivateNetwork = false } = {}) {
let url;
try {
url = new URL(String(rawUrl || ''));
} catch {
throw new Error('Ogiltig URL');
}
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('Endast http/https tillåts');
}
if (allowList.has(url.hostname)) return url;
// Om värden redan är en literal IP behöver vi ingen DNS-lookup.
if (net.isIP(url.hostname)) {
if (!allowPrivateNetwork && isPrivateIp(url.hostname)) throw new Error('Adressen pekar mot ett internt nät');
return url;
}
let resolved;
try {
resolved = await dns.lookup(url.hostname, { all: true });
} catch {
throw new Error('Kunde inte slå upp värdnamnet');
}
if (!resolved.length) {
throw new Error('Kunde inte slå upp värdnamnet');
}
if (!allowPrivateNetwork && resolved.some(entry => isPrivateIp(entry.address))) {
throw new Error('Adressen pekar mot ett internt nät');
}
return url;
}