Tau.Acuvim/portal/frontend/src/components/layout/AppLayout.tsx
Diseri Pearson e9143f8c27 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>
2026-05-19 20:04:18 +02:00

130 lines
4.3 KiB
TypeScript

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';
import { useBranding } from '../../hooks/useBranding';
import { useAppInfo } from '../../hooks/useAppInfo';
const { Header, Sider, Content, Footer } = Layout;
const { Text } = Typography;
export function AppLayout() {
const { user, logout } = useAuth();
const { branding } = useBranding();
const { isAdmin: adminMode } = useAppInfo();
const navigate = useNavigate();
const location = useLocation();
const userIsAdmin = user?.roles.includes('Admin') ?? false;
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
const adminItems = adminMode
? [{ key: '/admin/customers', icon: <TeamOutlined />, label: 'Customers' }]
: [{ key: '/admin/sites', icon: <ApartmentOutlined />, label: 'Sites' }];
// Measurements page is per-customer data: only meaningful in Client mode.
// In Admin mode the equivalent is the Customer detail measurements tab.
const measurementsItem = adminMode
? []
: [{ key: '/measurements', icon: <TableOutlined />, label: 'Measurements' }];
const items = [
{ key: '/', icon: <DashboardOutlined />, label: 'Dashboard' },
{ key: '/dashboards', icon: <LineChartOutlined />, label: 'Dashboards' },
...measurementsItem,
...(userIsAdmin
? [...adminItems, { key: '/settings', icon: <SettingOutlined />, label: 'Settings' }]
: []),
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: branding.primaryColor,
paddingInline: 24,
}}
>
<Space>
{branding.logoUrl && (
<img
src={branding.logoUrl}
alt=""
style={{ maxHeight: 36, background: 'rgba(255,255,255,0.1)', padding: 2, borderRadius: 2 }}
/>
)}
<Text strong style={{ color: '#fff', fontSize: 18 }}>
{branding.applicationName}
</Text>
{adminMode && <Tag color="gold" style={{ marginLeft: 8 }}>FLEET ADMIN</Tag>}
</Space>
<UserMenu
displayName={user?.displayName ?? user?.email ?? ''}
onProfile={() => navigate('/profile')}
onLogout={handleLogout}
/>
</Header>
<Layout>
<Sider width={220} style={{ background: branding.secondaryColor }}>
<Menu
mode="inline"
theme="dark"
selectedKeys={[location.pathname]}
onClick={(e) => navigate(e.key)}
// 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>
<Layout>
<Content style={{ padding: 24, background: '#f5f5f5' }}>
<Outlet />
</Content>
{branding.footerText && (
<Footer style={{ textAlign: 'center', background: 'transparent', color: '#888' }}>
{branding.footerText}
</Footer>
)}
</Layout>
</Layout>
</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>
);
}