diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx index 13d6206..390ce36 100644 --- a/portal/frontend/src/App.tsx +++ b/portal/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { LoginPage } from './pages/LoginPage'; import { DashboardPage } from './pages/DashboardPage'; import { DashboardsPage } from './pages/DashboardsPage'; import { MeasurementsPage } from './pages/MeasurementsPage'; +import { MyProfilePage } from './pages/MyProfilePage'; import { AdminSitesPage } from './pages/AdminSitesPage'; import { AdminCustomersPage } from './pages/AdminCustomersPage'; import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage'; @@ -41,6 +42,7 @@ export default function App() { } /> } /> } /> + } /> { } } +export async function updateMyProfile(displayName: string): Promise { + const { data } = await api.put('/auth/me', { displayName }); + return data; +} + +export async function changeMyPassword(currentPassword: string, newPassword: string): Promise { + await api.post('/auth/me/change-password', { currentPassword, newPassword }); +} + function axiosStatus(err: unknown): number | undefined { if (typeof err === 'object' && err !== null && 'response' in err) { const resp = (err as { response?: { status?: number } }).response; diff --git a/portal/frontend/src/components/layout/AppLayout.tsx b/portal/frontend/src/components/layout/AppLayout.tsx index bc1df5c..9992594 100644 --- a/portal/frontend/src/components/layout/AppLayout.tsx +++ b/portal/frontend/src/components/layout/AppLayout.tsx @@ -1,7 +1,9 @@ -import { Layout, Menu, Button, Typography, Space, Tag } from 'antd'; +import { Layout, Menu, Button, Typography, Space, Tag, Dropdown } from 'antd'; +import type { MenuProps } from 'antd'; import { DashboardOutlined, SettingOutlined, LogoutOutlined, LineChartOutlined, ApartmentOutlined, TeamOutlined, TableOutlined, + UserOutlined, DownOutlined, } from '@ant-design/icons'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '../../hooks/useAuth'; @@ -67,20 +69,22 @@ export function AppLayout() { {adminMode && FLEET ADMIN} - - {user?.displayName ?? user?.email} - - + navigate('/profile')} + onLogout={handleLogout} + /> - + navigate(e.key)} - style={{ height: '100%', borderRight: 0 }} + // Transparent so the Sider's branded secondaryColor shows through + // instead of the dark theme's default #001529. + style={{ height: '100%', borderRight: 0, background: 'transparent' }} items={items} /> @@ -98,3 +102,28 @@ export function AppLayout() { ); } + +function UserMenu({ + displayName, onProfile, onLogout, +}: { + displayName: string; + onProfile: () => void; + onLogout: () => void; +}) { + const items: MenuProps['items'] = [ + { key: 'profile', label: 'My profile', icon: , onClick: onProfile }, + { type: 'divider' }, + { key: 'logout', label: 'Sign out', icon: , danger: true, onClick: onLogout }, + ]; + return ( + + + + ); +} diff --git a/portal/frontend/src/hooks/useAuth.tsx b/portal/frontend/src/hooks/useAuth.tsx index b1edaf7..8b87e9b 100644 --- a/portal/frontend/src/hooks/useAuth.tsx +++ b/portal/frontend/src/hooks/useAuth.tsx @@ -8,6 +8,9 @@ interface AuthContextValue { loading: boolean; login: (email: string, password: string) => Promise; logout: () => Promise; + // For self-service profile edits to refresh the cached header user without + // a /auth/me round-trip (the PUT /auth/me response already carries it). + setUser: (u: CurrentUser) => void; } const AuthContext = createContext(undefined); @@ -32,7 +35,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); }, []); - const value = useMemo(() => ({ user, loading, login, logout }), [user, loading, login, logout]); + const value = useMemo( + () => ({ user, loading, login, logout, setUser }), + [user, loading, login, logout], + ); return {children}; } diff --git a/portal/frontend/src/pages/MyProfilePage.tsx b/portal/frontend/src/pages/MyProfilePage.tsx new file mode 100644 index 0000000..f3c40d4 --- /dev/null +++ b/portal/frontend/src/pages/MyProfilePage.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import { + Alert, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tag, Typography, message, +} from 'antd'; +import { LockOutlined, SaveOutlined, UserOutlined } from '@ant-design/icons'; +import { useAuth } from '../hooks/useAuth'; +import { changeMyPassword, updateMyProfile } from '../api/auth'; + +const { Title, Text } = Typography; + +interface ProfileFormValues { + displayName: string; +} + +interface PasswordFormValues { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +export function MyProfilePage() { + const { user, setUser } = useAuth(); + const [profileForm] = Form.useForm(); + const [passwordForm] = Form.useForm(); + const [profileError, setProfileError] = useState(null); + const [passwordError, setPasswordError] = useState(null); + const [savingProfile, setSavingProfile] = useState(false); + const [savingPassword, setSavingPassword] = useState(false); + + if (!user) return null; + + const handleProfileSubmit = async (values: ProfileFormValues) => { + setProfileError(null); + setSavingProfile(true); + try { + const updated = await updateMyProfile(values.displayName.trim()); + setUser(updated); + message.success('Profile updated'); + } catch (err: unknown) { + setProfileError(extractError(err)); + } finally { + setSavingProfile(false); + } + }; + + const handlePasswordSubmit = async (values: PasswordFormValues) => { + setPasswordError(null); + setSavingPassword(true); + try { + await changeMyPassword(values.currentPassword, values.newPassword); + passwordForm.resetFields(); + message.success('Password changed'); + } catch (err: unknown) { + setPasswordError(extractError(err)); + } finally { + setSavingPassword(false); + } + }; + + return ( + + + My profile}> + + + {user.email} + (read-only) + + + {user.roles.length === 0 + ? User + : user.roles.map(r => {r})} + + + + {profileError && } + + + form={profileForm} + layout="vertical" + initialValues={{ displayName: user.displayName }} + onFinish={handleProfileSubmit} + requiredMark={false} + > + + + + + + + + + + + + Change password}> + {passwordError && } + + + form={passwordForm} + layout="vertical" + onFinish={handlePasswordSubmit} + requiredMark={false} + > + + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('newPassword') === value) return Promise.resolve(); + return Promise.reject(new Error('Passwords do not match')); + }, + }), + ]} + > + + + + + + + + + + ); +} + +function extractError(err: unknown): string { + if (typeof err === 'object' && err !== null && 'response' in err) { + const data = (err as { response?: { data?: { error?: string } } }).response?.data; + if (data?.error) return data.error; + } + return 'Request failed.'; +} diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/AuthDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/AuthDtos.cs index 1f12824..788da45 100644 --- a/portal/src/Tau.Acuvim.Portal/DTOs/AuthDtos.cs +++ b/portal/src/Tau.Acuvim.Portal/DTOs/AuthDtos.cs @@ -3,3 +3,7 @@ namespace Tau.Acuvim.Portal.DTOs; public sealed record LoginRequest(string Email, string Password); public sealed record CurrentUserResponse(string Email, string DisplayName, IReadOnlyList Roles); + +public sealed record UpdateMyProfileRequest(string DisplayName); + +public sealed record ChangeMyPasswordRequest(string CurrentPassword, string NewPassword); diff --git a/portal/src/Tau.Acuvim.Portal/Endpoints/AuthEndpoints.cs b/portal/src/Tau.Acuvim.Portal/Endpoints/AuthEndpoints.cs index 80d4426..f301d7a 100644 --- a/portal/src/Tau.Acuvim.Portal/Endpoints/AuthEndpoints.cs +++ b/portal/src/Tau.Acuvim.Portal/Endpoints/AuthEndpoints.cs @@ -61,6 +61,51 @@ public static class AuthEndpoints return Results.Ok(new CurrentUserResponse(user.Email!, user.DisplayName, roles.ToArray())); }).RequireAuthorization(); + // Self-service: edit own displayName. Email stays immutable (it's the + // identity key) and roles aren't editable here (admin-only). + group.MapPut("/me", async ( + UpdateMyProfileRequest req, HttpContext ctx, UserManager users) => + { + var user = await users.GetUserAsync(ctx.User); + if (user is null || !user.IsActive) return Results.Unauthorized(); + + var name = (req.DisplayName ?? string.Empty).Trim(); + if (name.Length == 0) return Results.BadRequest(new { error = "DisplayName is required." }); + if (name.Length > 100) return Results.BadRequest(new { error = "DisplayName must be <= 100 chars." }); + + user.DisplayName = name; + var update = await users.UpdateAsync(user); + if (!update.Succeeded) + { + return Results.BadRequest(new { error = string.Join("; ", update.Errors.Select(e => e.Description)) }); + } + + var roles = await users.GetRolesAsync(user); + return Results.Ok(new CurrentUserResponse(user.Email!, user.DisplayName, roles.ToArray())); + }).RequireAuthorization(); + + // Self-service password change. Requires the current password — so a + // hijacked-cookie attacker can't lock the legitimate user out. + // Validates new password against Identity's policy (8+, upper/lower/digit). + group.MapPost("/me/change-password", async ( + ChangeMyPasswordRequest req, HttpContext ctx, UserManager users) => + { + var user = await users.GetUserAsync(ctx.User); + if (user is null || !user.IsActive) return Results.Unauthorized(); + + if (string.IsNullOrWhiteSpace(req.CurrentPassword) || string.IsNullOrWhiteSpace(req.NewPassword)) + { + return Results.BadRequest(new { error = "CurrentPassword and NewPassword are both required." }); + } + + var result = await users.ChangePasswordAsync(user, req.CurrentPassword, req.NewPassword); + if (!result.Succeeded) + { + return Results.BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) }); + } + return Results.NoContent(); + }).RequireAuthorization(); + // Cookie-only liveness check for Traefik forwardAuth on the Grafana embed. // Must stay cheap — Traefik calls it on every Grafana sub-request (panel, // CSS, JSON). No DB hit; the cookie's validity already implies a signed-in