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>
130 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|