mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -65,10 +65,40 @@ describe("RequestLogMiddleware", () => {
|
||||
originalUrl: "/api/bookings/1/submit?dry=1",
|
||||
baseUrl: "/api/bookings",
|
||||
route: { path: "/:id/submit" },
|
||||
headers: { "user-agent": "jest", "x-request-id": "req-42" },
|
||||
headers: {
|
||||
"user-agent": "jest",
|
||||
"x-request-id": "req-42",
|
||||
authorization: "Bearer tok",
|
||||
"x-client-app": "freight-backoffice",
|
||||
"current-project-id": "proj-3",
|
||||
},
|
||||
ip: "10.0.0.1",
|
||||
query: { dry: "1" },
|
||||
user: { id: "u-7" },
|
||||
user: {
|
||||
id: "u-7",
|
||||
sessionId: "sess-9",
|
||||
userType: "STAFF",
|
||||
status: "ACTIVE",
|
||||
username: "nati",
|
||||
email: "nati@example.com",
|
||||
phoneNumber: "0911000000",
|
||||
name: { en: "Nati" },
|
||||
roles: [{ key: "freight_operations" }],
|
||||
permissions: [{ key: "a" }, { key: "b" }],
|
||||
employee: {
|
||||
id: "emp-1",
|
||||
organizationId: "org-1",
|
||||
unitId: "unit-2",
|
||||
position: {
|
||||
id: "pos-5",
|
||||
key: "ops_officer",
|
||||
employeePositionId: "ep-6",
|
||||
isDelegate: true,
|
||||
delegatorId: "pos-1",
|
||||
positionType: { key: "operations" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
statusCode: 409,
|
||||
@@ -105,6 +135,29 @@ describe("RequestLogMiddleware", () => {
|
||||
bookingId: "b-1",
|
||||
booking: { outcome: "REJECTED" },
|
||||
});
|
||||
expect(JSON.parse(lines[0]).auth).toEqual({
|
||||
authenticated: true,
|
||||
hasBearer: true,
|
||||
clientApp: "freight-backoffice",
|
||||
userId: "u-7",
|
||||
sessionId: "sess-9",
|
||||
userType: "STAFF",
|
||||
userStatus: "ACTIVE",
|
||||
roles: ["freight_operations"],
|
||||
permissionCount: 2,
|
||||
employeeId: "emp-1",
|
||||
organizationId: "org-1",
|
||||
unitId: "unit-2",
|
||||
positionId: "pos-5",
|
||||
positionKey: "ops_officer",
|
||||
positionType: "operations",
|
||||
employeePositionId: "ep-6",
|
||||
isDelegate: true,
|
||||
delegatorId: "pos-1",
|
||||
projectId: "proj-3",
|
||||
});
|
||||
// No personal data reaches the line, whatever the token carried.
|
||||
expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/);
|
||||
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState } from "react";
|
||||
import { Mail, Loader2 } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Lets the signed-in backoffice user change their own email. Goes through
|
||||
* /me/contact/otp + /me/contact rather than the generic (unverified)
|
||||
* /auth/update-profile route, so the new address is proven before it's
|
||||
* written — see account.controller.ts on the API side.
|
||||
*/
|
||||
export function ChangeEmailCard() {
|
||||
const { user } = useAuth();
|
||||
const sendOtpMutation = useMutation(
|
||||
api.account.sendContactOtp.mutationOptions(),
|
||||
);
|
||||
const updateContactMutation = useMutation(
|
||||
api.account.updateContact.mutationOptions(),
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<"enterEmail" | "enterOtp">("enterEmail");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const closeDialog = () => {
|
||||
setOpen(false);
|
||||
setStep("enterEmail");
|
||||
setNewEmail("");
|
||||
setOtp("");
|
||||
setFormError("");
|
||||
};
|
||||
|
||||
const sendOtp = () => {
|
||||
setFormError("");
|
||||
if (!newEmail.trim()) {
|
||||
setFormError("Enter the new email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
sendOtpMutation.mutate(
|
||||
{ channel: "email", value: newEmail.trim() },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Verification code sent to ${result.sentTo}`);
|
||||
setStep("enterOtp");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const confirmOtp = () => {
|
||||
setFormError("");
|
||||
if (!otp.trim()) {
|
||||
setFormError("Enter the verification code.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateContactMutation.mutate(
|
||||
{ channel: "email", value: newEmail.trim(), otp: otp.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Email updated.");
|
||||
closeDialog();
|
||||
// Refetches the session so the new email shows everywhere — simplest
|
||||
// way to refresh the cached user without a dedicated context method.
|
||||
window.location.reload();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="size-4" />
|
||||
Email
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{user?.email ? `Current email: ${user.email}` : "Change your account email."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Change email
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change email</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "enterEmail"
|
||||
? "We'll send a verification code to the new address."
|
||||
: `Enter the code sent to ${newEmail}.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "enterEmail" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newEmail">New email</Label>
|
||||
<Input
|
||||
id="newEmail"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
/>
|
||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="otp">Verification code</Label>
|
||||
<Input
|
||||
id="otp"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
/>
|
||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
{step === "enterEmail" ? (
|
||||
<Button disabled={sendOtpMutation.isPending} onClick={sendOtp}>
|
||||
{sendOtpMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Send code"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={updateContactMutation.isPending} onClick={confirmOtp}>
|
||||
{updateContactMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Confirm"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from "react";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Lets the signed-in backoffice user change their own password. The account
|
||||
* is logged out on success — the old token was issued under the old
|
||||
* password, and this forces a clean re-login rather than trusting the
|
||||
* server to keep the existing session valid.
|
||||
*/
|
||||
export function ChangePasswordCard() {
|
||||
const { logout } = useAuth();
|
||||
const changePasswordMutation = useMutation(
|
||||
api.account.changePassword.mutationOptions(),
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const closeDialog = () => {
|
||||
setOpen(false);
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setFormError("");
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setFormError("");
|
||||
|
||||
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||
setFormError("All fields are required.");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setFormError("New password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (newPassword === oldPassword) {
|
||||
setFormError("New password must be different from the current one.");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setFormError("New password and confirmation do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
changePasswordMutation.mutate(
|
||||
{ oldPassword, newPassword, confirmPassword },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Password changed. Please sign in again.");
|
||||
closeDialog();
|
||||
setTimeout(logout, 1200);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-4" />
|
||||
Password
|
||||
</CardTitle>
|
||||
<CardDescription>Change the password for your account.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Change password
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change password</DialogTitle>
|
||||
<DialogDescription>
|
||||
You'll be signed out and asked to log in again once it's changed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="oldPassword">Current password</Label>
|
||||
<Input
|
||||
id="oldPassword"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">New password</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm new password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={changePasswordMutation.isPending} onClick={submit}>
|
||||
{changePasswordMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Change password"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -319,7 +319,7 @@ export const TopBar = () => {
|
||||
{t("header.viewProfile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/change-password")}
|
||||
onClick={() => navigate("/dashboard/profile")}
|
||||
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5">
|
||||
<Key className="w-4 h-4 mr-3" />
|
||||
{t("header.changePassword")}
|
||||
|
||||
@@ -502,7 +502,7 @@ const Header = () => {
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
|
||||
onClick={() => navigate("/change-password")}
|
||||
onClick={() => navigate("/dashboard/profile")}
|
||||
>
|
||||
<Key className="w-4 h-4 text-primary-600 dark:text-primary-400" />
|
||||
<span className="font-medium">
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
||||
import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard";
|
||||
import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard";
|
||||
|
||||
export default function MyProfilePage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
|
||||
<ChangeEmailCard />
|
||||
<ChangePasswordCard />
|
||||
<div id="signature">
|
||||
<MySignatureCard />
|
||||
</div>
|
||||
|
||||
@@ -551,7 +551,7 @@ const Top: React.FC<HeaderProps> = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||
onClick={() => navigate("/change-password")}
|
||||
onClick={() => navigate("/dashboard/profile")}
|
||||
>
|
||||
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>{t("header.changePassword")}</span>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
|
||||
export type ContactChannel = "email" | "phone";
|
||||
|
||||
export interface ChangePasswordPayload {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface SendContactOtpPayload {
|
||||
channel: ContactChannel;
|
||||
/** The NEW email/phone to verify — the OTP is sent here, not to the current one. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UpdateContactPayload extends SendContactOtpPayload {
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export const accountService = {
|
||||
/** PATCH /auth/change-password — generic IAM route, works for any user type. */
|
||||
changePassword: async (payload: ChangePasswordPayload): Promise<void> => {
|
||||
const response = await client.patch("/auth/change-password", payload);
|
||||
unwrap(response.data);
|
||||
},
|
||||
|
||||
/** POST /me/contact/otp — sends a code to the new email/phone to prove ownership. */
|
||||
sendContactOtp: async (
|
||||
payload: SendContactOtpPayload,
|
||||
): Promise<{ sentTo: string }> => {
|
||||
const response = await client.post<{ sentTo: string }>(
|
||||
"/me/contact/otp",
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** PATCH /me/contact — verifies the OTP and writes the new email/phone. */
|
||||
updateContact: async (
|
||||
payload: UpdateContactPayload,
|
||||
): Promise<{ success: true; value: string }> => {
|
||||
const response = await client.patch<{ success: true; value: string }>(
|
||||
"/me/contact",
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -136,6 +136,12 @@ import type {
|
||||
WarehouseZone,
|
||||
} from "@/types/warehouse";
|
||||
import { endpoint } from "@/utils/endpoint";
|
||||
import {
|
||||
accountService,
|
||||
type ChangePasswordPayload,
|
||||
type SendContactOtpPayload,
|
||||
type UpdateContactPayload,
|
||||
} from "./account.service";
|
||||
import {
|
||||
BookingListFilter,
|
||||
bookingsService,
|
||||
@@ -2182,6 +2188,29 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
account: {
|
||||
changePassword: endpoint<ChangePasswordPayload, void>(
|
||||
"me",
|
||||
"change-password",
|
||||
(payload) => accountService.changePassword(payload),
|
||||
),
|
||||
|
||||
sendContactOtp: endpoint<SendContactOtpPayload, { sentTo: string }>(
|
||||
"me",
|
||||
"send-contact-otp",
|
||||
(payload) => accountService.sendContactOtp(payload),
|
||||
),
|
||||
|
||||
updateContact: endpoint<
|
||||
UpdateContactPayload,
|
||||
{ success: true; value: string }
|
||||
>(
|
||||
"me",
|
||||
"update-contact",
|
||||
(payload) => accountService.updateContact(payload),
|
||||
),
|
||||
},
|
||||
|
||||
signatures: {
|
||||
mySignature: endpoint<void, SavedSignature | null>(
|
||||
"me",
|
||||
|
||||
@@ -20,7 +20,39 @@ interface LoggedRequest {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
ip?: string;
|
||||
query?: Record<string, unknown>;
|
||||
user?: Record<string, unknown> | null;
|
||||
/** Set by the IAM JwtGuard AFTER this middleware runs — read at emit time. */
|
||||
user?: AuthenticatedUser | null;
|
||||
currentUnitId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `TCurrentUser` (@tria-plc/api-common) the log line reads.
|
||||
* Everything here is an identifier, a key or a status — the personal fields on
|
||||
* that type (name, email, username, phoneNumber) are deliberately absent so
|
||||
* they cannot be picked up by accident.
|
||||
*/
|
||||
interface AuthenticatedUser {
|
||||
id?: string;
|
||||
sub?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
userType?: string;
|
||||
status?: string;
|
||||
roles?: { key?: string }[];
|
||||
permissions?: unknown[];
|
||||
employee?: {
|
||||
id?: string;
|
||||
organizationId?: string;
|
||||
unitId?: string;
|
||||
position?: {
|
||||
id?: string;
|
||||
key?: string;
|
||||
employeePositionId?: string;
|
||||
isDelegate?: boolean;
|
||||
delegatorId?: string;
|
||||
positionType?: { key?: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface LoggedResponse {
|
||||
@@ -35,13 +67,48 @@ const header = (req: LoggedRequest, name: string): string | undefined => {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
};
|
||||
|
||||
const userId = (req: LoggedRequest): string | undefined => {
|
||||
const userId = (req: LoggedRequest): string | undefined =>
|
||||
req.user?.id ?? req.user?.sub ?? req.user?.userId;
|
||||
|
||||
/**
|
||||
* Who the caller was acting as — WITHOUT any personal data. Ids, role/position
|
||||
* keys and statuses only: enough to answer "which desk did this", "was it a
|
||||
* delegate", "which tenant", and to spot an authorization problem, with nothing
|
||||
* that identifies the human behind the account beyond the opaque user id.
|
||||
*
|
||||
* `authenticated: false` with `hasBearer: true` is the signature of a rejected
|
||||
* token (expired session, bad signature) as opposed to a missing one.
|
||||
*/
|
||||
const authContext = (req: LoggedRequest): Record<string, unknown> => {
|
||||
const user = req.user;
|
||||
if (!user) return undefined;
|
||||
const id = user.id ?? user.sub ?? user.userId;
|
||||
return typeof id === "string" || typeof id === "number"
|
||||
? String(id)
|
||||
: undefined;
|
||||
const position = user?.employee?.position;
|
||||
return {
|
||||
authenticated: Boolean(user),
|
||||
hasBearer: header(req, "authorization")?.startsWith("Bearer ") ?? false,
|
||||
// Which frontend called — /auth/login rejects cross-audience credentials on it.
|
||||
clientApp: header(req, "x-client-app"),
|
||||
userId: userId(req),
|
||||
sessionId: user?.sessionId,
|
||||
userType: user?.userType,
|
||||
userStatus: user?.status,
|
||||
roles: user?.roles?.map((role) => role.key).filter(Boolean),
|
||||
// Count only: the full grant list is hundreds of keys and would dwarf the line.
|
||||
permissionCount: user?.permissions?.length,
|
||||
employeeId: user?.employee?.id,
|
||||
organizationId: user?.employee?.organizationId,
|
||||
unitId: user?.employee?.unitId ?? req.currentUnitId,
|
||||
positionId: position?.id,
|
||||
positionKey: position?.key,
|
||||
positionType: position?.positionType?.key,
|
||||
employeePositionId: position?.employeePositionId,
|
||||
// Acting on someone else's behalf — the first thing to check when a staff
|
||||
// action lands under an unexpected desk.
|
||||
isDelegate: position?.isDelegate,
|
||||
delegatorId: position?.delegatorId,
|
||||
// Tenant/scope headers the frontends send alongside the token.
|
||||
projectId:
|
||||
header(req, "current-project-id") ?? header(req, "x-current-project-id"),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -101,6 +168,9 @@ export class RequestLogMiddleware implements NestMiddleware {
|
||||
status,
|
||||
durationMs,
|
||||
userId: userId(req),
|
||||
// Read at emit time on purpose: the guard populates req.user long
|
||||
// after this middleware handed control on.
|
||||
auth: authContext(req),
|
||||
ip: req.ip,
|
||||
userAgent: header(req, "user-agent"),
|
||||
query:
|
||||
|
||||
Reference in New Issue
Block a user