Portal: self-service My profile + secondaryColor on the sidebar

Closes the last two outstanding items from FrontEndPrompt.txt's
original phase list.

Frontend
- New MyProfilePage at /profile, any authenticated user. Two cards:
  read-only email + roles + editable displayName; current/new/confirm
  password change with client-side AntD policy validation matching
  Identity's backend rules.
- AppLayout: replace the inline displayName + Sign out with a Dropdown
  on a user-icon button. Items are "My profile" → /profile and
  "Sign out". Header is cleaner; logout still one click.
- AppLayout: Sider now uses branding.secondaryColor with Menu
  theme="dark" + transparent background so the brand colour shows
  through. Sidebar finally reflects the secondary brand colour like
  the header reflects primary.
- useAuth exposes setUser so a successful profile save updates the
  header name without a /auth/me round-trip (PUT already returns
  the updated CurrentUserResponse).

Backend
- PUT /api/auth/me — update own displayName. Email and roles stay
  immutable here (admin-only for role changes). Returns the updated
  CurrentUserResponse.
- POST /api/auth/me/change-password — requires both current and new
  password. Wraps UserManager.ChangePasswordAsync so Identity's
  password policy (8+, upper, lower, digit) runs server-side and the
  current password is verified before any change.

Curl-verified
- PUT /me with new displayName → 200 with updated user.
- change-password with wrong current → 400 "Incorrect password."
- change-password with weak new → 400 listing each policy violation.
- change-password valid + revert → 204 / 204.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diseri Pearson 2026-05-19 20:04:18 +02:00
parent e2cbb83397
commit e9143f8c27
7 changed files with 276 additions and 10 deletions

View File

@ -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() {
<Route index element={<DashboardPage />} />
<Route path="dashboards" element={<DashboardsPage />} />
<Route path="measurements" element={<MeasurementsPage />} />
<Route path="profile" element={<MyProfilePage />} />
<Route
path="admin/sites"
element={

View File

@ -25,6 +25,15 @@ export async function fetchCurrentUser(): Promise<CurrentUser | null> {
}
}
export async function updateMyProfile(displayName: string): Promise<CurrentUser> {
const { data } = await api.put<CurrentUser>('/auth/me', { displayName });
return data;
}
export async function changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
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;

View File

@ -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() {
</Text>
{adminMode && <Tag color="gold" style={{ marginLeft: 8 }}>FLEET ADMIN</Tag>}
</Space>
<Space>
<Text style={{ color: '#cbd5e1' }}>{user?.displayName ?? user?.email}</Text>
<Button icon={<LogoutOutlined />} onClick={handleLogout} type="text" style={{ color: '#fff' }}>
Sign out
</Button>
</Space>
<UserMenu
displayName={user?.displayName ?? user?.email ?? ''}
onProfile={() => navigate('/profile')}
onLogout={handleLogout}
/>
</Header>
<Layout>
<Sider width={220} style={{ background: '#fff' }}>
<Sider width={220} style={{ background: branding.secondaryColor }}>
<Menu
mode="inline"
theme="dark"
selectedKeys={[location.pathname]}
onClick={(e) => 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}
/>
</Sider>
@ -98,3 +102,28 @@ export function AppLayout() {
</Layout>
);
}
function UserMenu({
displayName, onProfile, onLogout,
}: {
displayName: string;
onProfile: () => void;
onLogout: () => void;
}) {
const items: MenuProps['items'] = [
{ key: 'profile', label: 'My profile', icon: <UserOutlined />, onClick: onProfile },
{ type: 'divider' },
{ key: 'logout', label: 'Sign out', icon: <LogoutOutlined />, danger: true, onClick: onLogout },
];
return (
<Dropdown menu={{ items }} placement="bottomRight" trigger={['click']}>
<Button type="text" style={{ color: '#fff' }}>
<Space size={4}>
<UserOutlined />
{displayName}
<DownOutlined style={{ fontSize: 10 }} />
</Space>
</Button>
</Dropdown>
);
}

View File

@ -8,6 +8,9 @@ interface AuthContextValue {
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
// 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<AuthContextValue | undefined>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

View File

@ -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<ProfileFormValues>();
const [passwordForm] = Form.useForm<PasswordFormValues>();
const [profileError, setProfileError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(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 (
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Card title={<Space><UserOutlined /><Title level={5} style={{ margin: 0 }}>My profile</Title></Space>}>
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="Email">
<Text code>{user.email}</Text>
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>(read-only)</Text>
</Descriptions.Item>
<Descriptions.Item label="Roles">
{user.roles.length === 0
? <Tag>User</Tag>
: user.roles.map(r => <Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>{r}</Tag>)}
</Descriptions.Item>
</Descriptions>
{profileError && <Alert type="error" message={profileError} style={{ marginBottom: 16 }} />}
<Form<ProfileFormValues>
form={profileForm}
layout="vertical"
initialValues={{ displayName: user.displayName }}
onFinish={handleProfileSubmit}
requiredMark={false}
>
<Form.Item
name="displayName"
label="Display name"
rules={[
{ required: true, message: 'Display name is required' },
{ max: 100, message: 'Max 100 characters' },
]}
>
<Input autoComplete="name" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={savingProfile}>
Save profile
</Button>
</Form.Item>
</Form>
</Card>
</Col>
<Col xs={24} md={12}>
<Card title={<Space><LockOutlined /><Title level={5} style={{ margin: 0 }}>Change password</Title></Space>}>
{passwordError && <Alert type="error" message={passwordError} style={{ marginBottom: 16 }} />}
<Form<PasswordFormValues>
form={passwordForm}
layout="vertical"
onFinish={handlePasswordSubmit}
requiredMark={false}
>
<Form.Item
name="currentPassword"
label="Current password"
rules={[{ required: true, message: 'Current password is required' }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item
name="newPassword"
label="New password"
rules={[
{ required: true, message: 'New password is required' },
{ min: 8, message: 'At least 8 characters' },
{
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/,
message: 'Must contain upper, lower, and a digit',
},
]}
hasFeedback
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="Confirm new password"
dependencies={['newPassword']}
hasFeedback
rules={[
{ required: true, message: 'Confirm the new password' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) return Promise.resolve();
return Promise.reject(new Error('Passwords do not match'));
},
}),
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" icon={<LockOutlined />} loading={savingPassword}>
Change password
</Button>
</Form.Item>
</Form>
</Card>
</Col>
</Row>
);
}
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.';
}

View File

@ -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<string> Roles);
public sealed record UpdateMyProfileRequest(string DisplayName);
public sealed record ChangeMyPasswordRequest(string CurrentPassword, string NewPassword);

View File

@ -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<ApplicationUser> 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<ApplicationUser> 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