diff --git a/src/Budget.Api/Budget.Api.csproj b/src/Budget.Api/Budget.Api.csproj index e2cddb3..3cff11f 100644 --- a/src/Budget.Api/Budget.Api.csproj +++ b/src/Budget.Api/Budget.Api.csproj @@ -7,6 +7,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Budget.Api/Controllers/MeController.cs b/src/Budget.Api/Controllers/MeController.cs new file mode 100644 index 0000000..65ebcba --- /dev/null +++ b/src/Budget.Api/Controllers/MeController.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Budget.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class MeController : ControllerBase +{ + [HttpGet] + public IActionResult Get() + { + var sub = User.FindFirst("sub")?.Value; + var email = User.FindFirst("email")?.Value; + return Ok(new { sub, email }); + } +} diff --git a/src/Budget.Api/Program.cs b/src/Budget.Api/Program.cs index 4ba6903..45c15ab 100644 --- a/src/Budget.Api/Program.cs +++ b/src/Budget.Api/Program.cs @@ -1,5 +1,8 @@ using Budget.Api.Data; +using Budget.Api.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); @@ -11,6 +14,21 @@ var connStr = builder.Configuration.GetConnectionString("DefaultConnection") $"Password={builder.Configuration["POSTGRES_PASSWORD"] ?? "changeme"}"; builder.Services.AddDbContext(opt => opt.UseNpgsql(connStr)); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = builder.Configuration["AUTH__AUTHORITY"]; + options.Audience = builder.Configuration["AUTH__AUDIENCE"]; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + }; + }); + +builder.Services.AddAuthorization(); builder.Services.AddControllers(); var app = builder.Build(); @@ -18,6 +36,11 @@ var app = builder.Build(); app.UseDefaultFiles(); app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseMiddleware(); + app.MapControllers(); app.MapFallbackToFile("index.html"); diff --git a/src/Budget.Api/Services/KnownUserMiddleware.cs b/src/Budget.Api/Services/KnownUserMiddleware.cs new file mode 100644 index 0000000..e39a8da --- /dev/null +++ b/src/Budget.Api/Services/KnownUserMiddleware.cs @@ -0,0 +1,48 @@ +using Budget.Api.Data; +using Budget.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace Budget.Api.Services; + +public class KnownUserMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, AppDbContext db) + { + if (context.User.Identity?.IsAuthenticated == true) + { + var sub = context.User.FindFirst("sub")?.Value; + var email = context.User.FindFirst("email")?.Value; + var name = context.User.FindFirst("name")?.Value; + + if (sub != null && email != null && name != null) + { + var known = await db.KnownUsers.FindAsync(sub); + if (known is null) + { + db.KnownUsers.Add(new KnownUser { Id = sub, Email = email, Name = name, LastSeenAt = DateTimeOffset.UtcNow }); + } + else + { + known.Email = email; + known.Name = name; + known.LastSeenAt = DateTimeOffset.UtcNow; + } + + // Resolve pending shares for this user's email + var pending = await db.BudgetShares + .Where(s => s.IsPending && s.SharedWithEmail == email) + .ToListAsync(); + + foreach (var share in pending) + { + share.SharedWithUserId = sub; + share.IsPending = false; + } + + await db.SaveChangesAsync(); + } + } + + await next(context); + } +} diff --git a/src/Budget.Client/package-lock.json b/src/Budget.Client/package-lock.json index 78ba4a9..a595c83 100644 --- a/src/Budget.Client/package-lock.json +++ b/src/Budget.Client/package-lock.json @@ -8,8 +8,10 @@ "name": "budget-client", "version": "0.0.0", "dependencies": { + "oidc-client-ts": "^3.5.0", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1306,6 +1308,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1837,6 +1852,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2204,6 +2228,18 @@ "dev": true, "license": "MIT" }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2364,6 +2400,44 @@ "react": "^19.2.5" } }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -2421,6 +2495,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/src/Budget.Client/package.json b/src/Budget.Client/package.json index 645d66e..4ae7988 100644 --- a/src/Budget.Client/package.json +++ b/src/Budget.Client/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "oidc-client-ts": "^3.5.0", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/Budget.Client/src/App.tsx b/src/Budget.Client/src/App.tsx index a66b5ef..0fb36e8 100644 --- a/src/Budget.Client/src/App.tsx +++ b/src/Budget.Client/src/App.tsx @@ -1,122 +1,44 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider } from './auth/AuthContext'; +import { AuthGuard } from './auth/AuthGuard'; +import { CallbackPage } from './pages/CallbackPage'; +import { BudgetsPage } from './pages/BudgetsPage'; +import { IncomePage } from './pages/IncomePage'; +import { OutgoPage } from './pages/OutgoPage'; +import { SummaryPage } from './pages/SummaryPage'; +import { SettingsPage } from './pages/SettingsPage'; function App() { - const [count, setCount] = useState(0) - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) + + + + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + ); } -export default App +export default App; diff --git a/src/Budget.Client/src/auth/AuthContext.tsx b/src/Budget.Client/src/auth/AuthContext.tsx new file mode 100644 index 0000000..3750d87 --- /dev/null +++ b/src/Budget.Client/src/auth/AuthContext.tsx @@ -0,0 +1,58 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import { UserManager } from 'oidc-client-ts'; +import type { User } from 'oidc-client-ts'; +import { authConfig } from './authConfig'; + +interface AuthContextValue { + user: User | null; + isLoading: boolean; + login: () => void; + logout: () => void; + getToken: () => string | null; +} + +const AuthContext = createContext(null); + +const userManager = new UserManager(authConfig); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + userManager.getUser().then(u => { + setUser(u); + setIsLoading(false); + }); + + const onUserLoaded = (u: User) => setUser(u); + const onUserUnloaded = () => setUser(null); + + userManager.events.addUserLoaded(onUserLoaded); + userManager.events.addUserUnloaded(onUserUnloaded); + + return () => { + userManager.events.removeUserLoaded(onUserLoaded); + userManager.events.removeUserUnloaded(onUserUnloaded); + }; + }, []); + + const login = () => userManager.signinRedirect(); + const logout = () => userManager.signoutRedirect(); + const getToken = () => user?.access_token ?? null; + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} + +export { userManager }; diff --git a/src/Budget.Client/src/auth/AuthGuard.tsx b/src/Budget.Client/src/auth/AuthGuard.tsx new file mode 100644 index 0000000..06a6de4 --- /dev/null +++ b/src/Budget.Client/src/auth/AuthGuard.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react'; +import { useAuth } from './AuthContext'; + +export function AuthGuard({ children }: { children: ReactNode }) { + const { user, isLoading, login } = useAuth(); + + if (isLoading) return
Loading...
; + + if (!user) { + login(); + return null; + } + + return <>{children}; +} diff --git a/src/Budget.Client/src/auth/authConfig.ts b/src/Budget.Client/src/auth/authConfig.ts new file mode 100644 index 0000000..7150098 --- /dev/null +++ b/src/Budget.Client/src/auth/authConfig.ts @@ -0,0 +1,11 @@ +import type { UserManagerSettings } from 'oidc-client-ts'; + +export const authConfig: UserManagerSettings = { + authority: import.meta.env.VITE_AUTH_AUTHORITY, + client_id: import.meta.env.VITE_AUTH_CLIENT_ID, + redirect_uri: import.meta.env.VITE_AUTH_REDIRECT_URI, + response_type: 'code', + scope: 'openid profile email', + post_logout_redirect_uri: import.meta.env.VITE_AUTH_REDIRECT_URI?.replace('/callback', ''), + automaticSilentRenew: true, +}; diff --git a/src/Budget.Client/src/pages/BudgetsPage.tsx b/src/Budget.Client/src/pages/BudgetsPage.tsx new file mode 100644 index 0000000..13dfb11 --- /dev/null +++ b/src/Budget.Client/src/pages/BudgetsPage.tsx @@ -0,0 +1,3 @@ +export function BudgetsPage() { + return
Budgets — coming soon
; +} diff --git a/src/Budget.Client/src/pages/CallbackPage.tsx b/src/Budget.Client/src/pages/CallbackPage.tsx new file mode 100644 index 0000000..0fdd2ab --- /dev/null +++ b/src/Budget.Client/src/pages/CallbackPage.tsx @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { userManager } from '../auth/AuthContext'; + +export function CallbackPage() { + const navigate = useNavigate(); + + useEffect(() => { + userManager.signinRedirectCallback() + .then(() => navigate('/budgets')) + .catch(err => { + console.error('OIDC callback error', err); + navigate('/'); + }); + }, [navigate]); + + return
Signing in...
; +} diff --git a/src/Budget.Client/src/pages/IncomePage.tsx b/src/Budget.Client/src/pages/IncomePage.tsx new file mode 100644 index 0000000..0fe2d9d --- /dev/null +++ b/src/Budget.Client/src/pages/IncomePage.tsx @@ -0,0 +1,3 @@ +export function IncomePage() { + return
Income — coming soon
; +} diff --git a/src/Budget.Client/src/pages/OutgoPage.tsx b/src/Budget.Client/src/pages/OutgoPage.tsx new file mode 100644 index 0000000..a8bbec8 --- /dev/null +++ b/src/Budget.Client/src/pages/OutgoPage.tsx @@ -0,0 +1,3 @@ +export function OutgoPage() { + return
Outgo — coming soon
; +} diff --git a/src/Budget.Client/src/pages/SettingsPage.tsx b/src/Budget.Client/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..434454b --- /dev/null +++ b/src/Budget.Client/src/pages/SettingsPage.tsx @@ -0,0 +1,3 @@ +export function SettingsPage() { + return
Settings — coming soon
; +} diff --git a/src/Budget.Client/src/pages/SummaryPage.tsx b/src/Budget.Client/src/pages/SummaryPage.tsx new file mode 100644 index 0000000..4d1b2ed --- /dev/null +++ b/src/Budget.Client/src/pages/SummaryPage.tsx @@ -0,0 +1,3 @@ +export function SummaryPage() { + return
Summary — coming soon
; +}