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}
- } onClick={handleLogout} type="text" style={{ color: '#fff' }}>
- Sign out
-
-
+ navigate('/profile')}
+ onLogout={handleLogout}
+ />
-
+
@@ -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 && }
+
+
+
+
+
+ } loading={savingProfile}>
+ Save profile
+
+
+
+
+
+
+
+ Change password}>
+ {passwordError && }
+
+
+
+
+
+
+
+ ({
+ validator(_, value) {
+ if (!value || getFieldValue('newPassword') === value) return Promise.resolve();
+ return Promise.reject(new Error('Passwords do not match'));
+ },
+ }),
+ ]}
+ >
+
+
+
+ } loading={savingPassword}>
+ Change password
+
+
+
+
+
+
+ );
+}
+
+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