diff --git a/api/transactions-delete/index.js b/api/transactions-delete/index.js index 57cec5a..e307b5e 100644 --- a/api/transactions-delete/index.js +++ b/api/transactions-delete/index.js @@ -1,5 +1,11 @@ import { CosmosClient } from "@azure/cosmos"; +function getClientPrincipal(req) { + const v = req.headers["x-ms-client-principal"]; + if (!v) return null; + return JSON.parse(Buffer.from(v, "base64").toString("utf8")); +} + const endpoint = process.env.COSMOS_ENDPOINT; const key = process.env.COSMOS_KEY; const databaseId = process.env.COSMOS_DB || "finance"; @@ -7,21 +13,29 @@ const containerId = process.env.COSMOS_CONTAINER || "transactions"; export default async function (context, req) { try { - const id = context.bindingData.id; - const userId = req.query.userId; // partition key + const principal = getClientPrincipal(req); + if (!principal) { + context.res = { status: 401, jsonBody: { error: "Unauthorized" } }; + return; + } + const userId = principal.userId; - if (!id || !userId) { - context.res = { status: 400, jsonBody: { error: "id and userId (partition key) required" } }; + // id por ruta: /api/transactions/{id} + const id = context.bindingData?.id || req.query?.id; + if (!id) { + context.res = { status: 400, jsonBody: { error: "id requerido en ruta o query" } }; return; } const client = new CosmosClient({ endpoint, key }); const container = client.database(databaseId).container(containerId); - + // importante: siempre pasar partitionKey = userId await container.item(id, userId).delete(); + context.res = { status: 204 }; } catch (e) { context.log.error(e); - context.res = { status: 500, jsonBody: { error: e.message } }; + // Si el doc no existe o no pertenece al usuario, devolvemos 404 + context.res = { status: 404, jsonBody: { error: "Not found" } }; } } diff --git a/api/transactions-get/index.js b/api/transactions-get/index.js index 17d42fc..0af3a28 100644 --- a/api/transactions-get/index.js +++ b/api/transactions-get/index.js @@ -1,5 +1,11 @@ import { CosmosClient } from "@azure/cosmos"; +function getClientPrincipal(req) { + const v = req.headers["x-ms-client-principal"]; + if (!v) return null; + return JSON.parse(Buffer.from(v, "base64").toString("utf8")); +} + const endpoint = process.env.COSMOS_ENDPOINT; const key = process.env.COSMOS_KEY; const databaseId = process.env.COSMOS_DB || "finance"; @@ -7,34 +13,35 @@ const containerId = process.env.COSMOS_CONTAINER || "transactions"; export default async function (context, req) { try { - const userId = req.query.userId; // opcional - const month = req.query.month; // opcional: "YYYY-MM" + const principal = getClientPrincipal(req); + if (!principal) { + context.res = { status: 401, jsonBody: { error: "Unauthorized" } }; + return; + } + const userId = principal.userId; // <- el dueño de la partición + const month = req.query?.month || ""; // "YYYY-MM" u "" para histórico const client = new CosmosClient({ endpoint, key }); const container = client.database(databaseId).container(containerId); - const where = []; - const params = []; - if (userId) { where.push("c.userId = @userId"); params.push({ name: "@userId", value: userId }); } - if (month) { where.push("STARTSWITH(c.date, @month)"); params.push({ name: "@month", value: month }); } + const where = ["c.userId = @userId"]; + const params = [{ name: "@userId", value: userId }]; - const query = `SELECT * FROM c ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY c.date DESC`; - context.log(`Query: ${query} | Params: ${JSON.stringify(params)}`); + if (month) { + where.push("STARTSWITH(c.date, @month)"); + params.push({ name: "@month", value: month }); + } + const query = `SELECT * FROM c WHERE ${where.join(" AND ")} ORDER BY c.date DESC`; const { resources } = await container.items.query({ query, parameters: params }).fetchAll(); - // >>> Fuerza JSON siempre <<< context.res = { status: 200, headers: { "Content-Type": "application/json" }, - body: JSON.stringify(resources ?? []) + body: JSON.stringify(resources ?? []), }; } catch (e) { context.log.error(e); - context.res = { - status: 500, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ error: String(e?.message || e) }) - }; + context.res = { status: 500, jsonBody: { error: String(e.message || e) } }; } } diff --git a/api/transactions-post/index.js b/api/transactions-post/index.js index eb733c0..c823701 100644 --- a/api/transactions-post/index.js +++ b/api/transactions-post/index.js @@ -1,43 +1,54 @@ import { CosmosClient } from "@azure/cosmos"; import { randomUUID } from "crypto"; -const { COSMOS_ENDPOINT, COSMOS_KEY, COSMOS_DB = "finance", COSMOS_CONTAINER = "transactions" } = process.env; + +function getClientPrincipal(req) { + const v = req.headers["x-ms-client-principal"]; + if (!v) return null; + return JSON.parse(Buffer.from(v, "base64").toString("utf8")); +} + +const endpoint = process.env.COSMOS_ENDPOINT; +const key = process.env.COSMOS_KEY; +const databaseId = process.env.COSMOS_DB || "finance"; +const containerId = process.env.COSMOS_CONTAINER || "transactions"; export default async function (context, req) { try { - // Asegura que obtengamos el body en Node v4: - let b = req.body; - if (!b) { - try { b = await req.json(); } catch { b = null; } + const principal = getClientPrincipal(req); + if (!principal) { + context.res = { status: 401, jsonBody: { error: "Unauthorized" } }; + return; } + const userId = principal.userId; - const errs = []; - if (!b) errs.push("body required"); - if (!b?.userId) errs.push("userId required"); - if (!b?.date) errs.push("date (YYYY-MM-DD) required"); - if (typeof b?.amount !== "number") errs.push("amount must be number"); - if (!b?.category) errs.push("category required"); - if (errs.length) return (context.res = { status: 400, jsonBody: { errors: errs } }); + const body = req.body || {}; + // ignoramos body.userId si llegara; usamos el del token + const { date, amount, category, note = "", account = "Other", tags = [] } = body; + + if (!date || typeof amount !== "number" || !category) { + context.res = { status: 400, jsonBody: { error: "date, amount (number) y category son requeridos" } }; + return; + } - const now = new Date().toISOString(); const item = { - id: randomUUID(), - userId: b.userId, - date: b.date, - amount: b.amount, - category: b.category, - note: b.note ?? "", - account: b.account ?? "Other", - tags: Array.isArray(b.tags) ? b.tags : [], - createdAt: now, - updatedAt: now + id: body.id || randomUUID(), + userId, // <- partición + date, // YYYY-MM-DD + amount, // negativo egreso / positivo ingreso + category, + note, + account, + tags, + createdAt: new Date().toISOString(), }; - const client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY }); - const container = client.database(COSMOS_DB).container(COSMOS_CONTAINER); - const { resource } = await container.items.create(item); + const client = new CosmosClient({ endpoint, key }); + const container = client.database(databaseId).container(containerId); + const { resource } = await container.items.create(item, { disableAutomaticIdGeneration: true }); + context.res = { status: 201, jsonBody: resource }; } catch (e) { context.log.error(e); - context.res = { status: 500, jsonBody: { error: e.message } }; + context.res = { status: 500, jsonBody: { error: String(e.message || e) } }; } } diff --git a/src/App.jsx b/src/App.jsx index 2a9acb6..6013dbc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,5 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; +import { getUser, login, logout } from './auth'; import { PlusCircle, Trash2, CreditCard, TrendingUp, TrendingDown, DollarSign, Download, Upload } from 'lucide-react'; import { PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { useTransactions } from './hooks/useTransactions'; // API hacia Azure Functions/Cosmos @@ -11,8 +12,28 @@ const FinanceTracker = () => { // Para pedir datos: si histórico => pasamos "" como month para traer todo const queryMonth = rangeMode === 'all' ? '' : month; + // --- Sesión Entra ID (SWA) --- + const [user, setUser] = useState(null); + useEffect(() => { getUser().then(setUser); }, []); + + // Si no hay usuario aún, puedes mostrar un loader o un botón de login. + // Con tu staticwebapp.config.json ya configurado, SWA redirige 401→/login, + // pero este fallback es útil por si accedes directo a /login o en local. + if (!user) { + return ( +
+

Finance Tracker

+

Necesitas iniciar sesión.

+ +
+ ); + } + + // === Datos desde Cosmos vía hook === - const userId = 'mario'; // luego tomar de Auth + const userId = user.userId; // o user.userDetails si prefieres particionar por email const { items, loading, error, create, remove } = useTransactions(userId, queryMonth); // Helpers para navegar meses @@ -187,6 +208,14 @@ const FinanceTracker = () => { +
+ {user.userDetails} + +
+ + {/* Tabs */}
{['dashboard', 'transacciones', 'tarjetas'].map(tab => ( diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..d1eadcd --- /dev/null +++ b/src/auth.js @@ -0,0 +1,9 @@ +// src/auth.js +export async function getUser() { + const res = await fetch('/.auth/me', { credentials: 'include' }); + if (!res.ok) return null; + const data = await res.json(); + return data?.clientPrincipal || null; // { userId, userDetails, userRoles, identityProvider } +} +export const login = () => (window.location.href = '/login'); +export const logout = () => (window.location.href = '/logout');