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:
parent
e2cbb83397
commit
e9143f8c27
@ -11,6 +11,7 @@ import { LoginPage } from './pages/LoginPage';
|
|||||||
import { DashboardPage } from './pages/DashboardPage';
|
import { DashboardPage } from './pages/DashboardPage';
|
||||||
import { DashboardsPage } from './pages/DashboardsPage';
|
import { DashboardsPage } from './pages/DashboardsPage';
|
||||||
import { MeasurementsPage } from './pages/MeasurementsPage';
|
import { MeasurementsPage } from './pages/MeasurementsPage';
|
||||||
|
import { MyProfilePage } from './pages/MyProfilePage';
|
||||||
import { AdminSitesPage } from './pages/AdminSitesPage';
|
import { AdminSitesPage } from './pages/AdminSitesPage';
|
||||||
import { AdminCustomersPage } from './pages/AdminCustomersPage';
|
import { AdminCustomersPage } from './pages/AdminCustomersPage';
|
||||||
import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage';
|
import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage';
|
||||||
@ -41,6 +42,7 @@ export default function App() {
|
|||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="dashboards" element={<DashboardsPage />} />
|
<Route path="dashboards" element={<DashboardsPage />} />
|
||||||
<Route path="measurements" element={<MeasurementsPage />} />
|
<Route path="measurements" element={<MeasurementsPage />} />
|
||||||
|
<Route path="profile" element={<MyProfilePage />} />
|
||||||
<Route
|
<Route
|
||||||
path="admin/sites"
|
path="admin/sites"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -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 {
|
function axiosStatus(err: unknown): number | undefined {
|
||||||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||||
const resp = (err as { response?: { status?: number } }).response;
|
const resp = (err as { response?: { status?: number } }).response;
|
||||||
|
|||||||
@ -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 {
|
import {
|
||||||
DashboardOutlined, SettingOutlined, LogoutOutlined,
|
DashboardOutlined, SettingOutlined, LogoutOutlined,
|
||||||
LineChartOutlined, ApartmentOutlined, TeamOutlined, TableOutlined,
|
LineChartOutlined, ApartmentOutlined, TeamOutlined, TableOutlined,
|
||||||
|
UserOutlined, DownOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
@ -67,20 +69,22 @@ export function AppLayout() {
|
|||||||
</Text>
|
</Text>
|
||||||
{adminMode && <Tag color="gold" style={{ marginLeft: 8 }}>FLEET ADMIN</Tag>}
|
{adminMode && <Tag color="gold" style={{ marginLeft: 8 }}>FLEET ADMIN</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
<Space>
|
<UserMenu
|
||||||
<Text style={{ color: '#cbd5e1' }}>{user?.displayName ?? user?.email}</Text>
|
displayName={user?.displayName ?? user?.email ?? ''}
|
||||||
<Button icon={<LogoutOutlined />} onClick={handleLogout} type="text" style={{ color: '#fff' }}>
|
onProfile={() => navigate('/profile')}
|
||||||
Sign out
|
onLogout={handleLogout}
|
||||||
</Button>
|
/>
|
||||||
</Space>
|
|
||||||
</Header>
|
</Header>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Sider width={220} style={{ background: '#fff' }}>
|
<Sider width={220} style={{ background: branding.secondaryColor }}>
|
||||||
<Menu
|
<Menu
|
||||||
mode="inline"
|
mode="inline"
|
||||||
|
theme="dark"
|
||||||
selectedKeys={[location.pathname]}
|
selectedKeys={[location.pathname]}
|
||||||
onClick={(e) => navigate(e.key)}
|
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}
|
items={items}
|
||||||
/>
|
/>
|
||||||
</Sider>
|
</Sider>
|
||||||
@ -98,3 +102,28 @@ export function AppLayout() {
|
|||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,9 @@ interface AuthContextValue {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
logout: () => 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);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
@ -32,7 +35,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(null);
|
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>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
171
portal/frontend/src/pages/MyProfilePage.tsx
Normal file
171
portal/frontend/src/pages/MyProfilePage.tsx
Normal 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.';
|
||||||
|
}
|
||||||
@ -3,3 +3,7 @@ namespace Tau.Acuvim.Portal.DTOs;
|
|||||||
public sealed record LoginRequest(string Email, string Password);
|
public sealed record LoginRequest(string Email, string Password);
|
||||||
|
|
||||||
public sealed record CurrentUserResponse(string Email, string DisplayName, IReadOnlyList<string> Roles);
|
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);
|
||||||
|
|||||||
@ -61,6 +61,51 @@ public static class AuthEndpoints
|
|||||||
return Results.Ok(new CurrentUserResponse(user.Email!, user.DisplayName, roles.ToArray()));
|
return Results.Ok(new CurrentUserResponse(user.Email!, user.DisplayName, roles.ToArray()));
|
||||||
}).RequireAuthorization();
|
}).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.
|
// Cookie-only liveness check for Traefik forwardAuth on the Grafana embed.
|
||||||
// Must stay cheap — Traefik calls it on every Grafana sub-request (panel,
|
// 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
|
// CSS, JSON). No DB hit; the cookie's validity already implies a signed-in
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user