Initial commit
This commit is contained in:
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.git
|
||||||
|
**/dist
|
||||||
|
server/data
|
||||||
|
*.md
|
||||||
Executable
+18
@@ -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
@@ -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
@@ -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"]
|
||||||
Executable
+9
@@ -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.');
|
||||||
Executable
+25
@@ -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>
|
||||||
+7457
File diff suppressed because it is too large
Load Diff
Executable
+32
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Executable
+23
@@ -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 |
Executable
+108
@@ -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"
|
||||||
|
>
|
||||||
|
Gå 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+81
@@ -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),
|
||||||
|
};
|
||||||
Executable
+394
@@ -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 på 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 så du slipper skriva modellnamn på 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+555
@@ -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 på 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 på 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 på 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 få allt. Beloppet jämförs på 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">
|
||||||
|
På 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 på 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+172
@@ -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
@@ -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
@@ -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 på 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, så 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 på
|
||||||
|
</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 på 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 få 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+126
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+162
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+165
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+25
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+43
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+87
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+55
@@ -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); }
|
||||||
|
}
|
||||||
Executable
+13
@@ -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>
|
||||||
|
);
|
||||||
Executable
+772
@@ -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">Så 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 på en skärmdump och svara på 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 på 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+170
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+236
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+474
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+1288
File diff suppressed because it is too large
Load Diff
Executable
+55
@@ -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' });
|
||||||
|
}
|
||||||
Executable
+12
@@ -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: [],
|
||||||
|
};
|
||||||
Executable
+54
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+31
@@ -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
@@ -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];
|
||||||
|
}
|
||||||
Executable
+183
@@ -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;
|
||||||
|
}
|
||||||
Executable
+5
@@ -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
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+1075
File diff suppressed because it is too large
Load Diff
+892
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+16
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+1872
File diff suppressed because it is too large
Load Diff
Executable
+80
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user