Compare commits
9 commits
de556ba370
...
f1239ad593
| Author | SHA1 | Date | |
|---|---|---|---|
| f1239ad593 | |||
| f1ff0436dd | |||
| cba1475b33 | |||
| 39e13da41b | |||
| 7624327ab9 | |||
| 6efe23a9e4 | |||
| d46f480825 | |||
| 3d8655aa8a | |||
| 36ab2eba9b |
14 changed files with 393 additions and 114 deletions
|
|
@ -224,7 +224,7 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
|
|||
- **Schema**: `user` (→ users), `type` (text, e.g. `market:offer`), `title`, `message`, `link` (optional internal path), `read` (checkbox), timestamps.
|
||||
- **Access**: users read/update only their own; create/delete developer only (creation happens via `notifyUser`, which uses `overrideAccess`).
|
||||
- **Helper**: `notifyUser(payload, { userId, type?, title, message?, link? })` in `src/lib/notifications/index.ts` — fire-and-forget create. `notificationLabel(type)` maps types to short labels. Notification type strings: `market:offer`, `market:counter`, `market:accept`, `market:reject`, `market:withdrawn`, `market:sold`, `market:expired`.
|
||||
- **UI**: `NotificationsBell` (`src/components/frontend/notifications/NotificationsBell.tsx`) mounted in `SiteHeader`. Polls `/api/user-notifications?limit=12&sort=-createdAt` (cookie auth) every 30s, shows an unread-count badge, dropdown with unread highlight + relative time, click-to-open-link, mark-read on click, "Mark all read". Mark-read server actions live in `src/app/(frontend)/notifications/actions.ts` (`markNotificationsRead`, `markAllNotificationsRead`).
|
||||
- **UI**: `NotificationsBell` (`src/components/frontend/notifications/NotificationsBell.tsx`) mounted in `SiteHeader`. Polls `/api/user-notifications?limit=12&sort=-createdAt` (cookie auth) every 30s, shows an unread-count badge, dropdown with unread highlight + relative time, click-to-open-link, per-row mark-as-read check button on unread rows (`stopPropagation` — marks read without navigating, dropdown stays open), "Mark all read". Mark-read server actions live in `src/app/(frontend)/notifications/actions.ts` (`markNotificationsRead`, `markAllNotificationsRead`).
|
||||
- **Wiring**: negotiation flows in `market/actions.ts` notify the counterparty at every step; `expireNegotiationsForListing` notifies interested buyers when a listing sells/cancels/expires.
|
||||
|
||||
## Narrative Events System
|
||||
|
|
|
|||
6
TODO.md
6
TODO.md
|
|
@ -3,11 +3,11 @@
|
|||
## Full Feature Additions, Top Priority
|
||||
|
||||
- [x] **Leadership evaluations and performance rating system** - Members of the unit should be able to rate the performance of their direct team leaders (full performance rating) and submit opinions on separate leaders (a more limited rating system for non-direct supervisors.) These ratings should appear anonymously on the leader's service record page. The ratings are divided by mission, so that they can accumulate over time to form a more accurate representation of the overall delta of a leader's performance over the course of multiple operations. These ratings appear as bars, red-to-green gradients, and percentage scores on the service record page. A small table showing the latest missions and the leader's overall rating are also visible below this bar. Additionally, leaders should be able to rate the performance of their subordinates. However, these ratings appear in a far more limited fashion on the subordinate's service record page. Instead of a breakdown with bars, percentages, latest missions, etc., they instead just get a text label indicating a summary of their performance from leadership. For example, if a subordinate is rated very highly by their leaders, their text label might say "OPERATOR PERFORMANCE LEVEL: EXCELLENT" in green text while a moderate review may say "OPERATOR PERFORMANCE LEVEL: MODERATE" in grey or blue text and a low rating might say "OPERATOR PERFORMANCE LEVEL: UNDER REVIEW" in red text. Preferably, there'd be at least 5 levels of performance instead of the 3 I used as examples.
|
||||
- [ ] **Ribbon overview and listing page** - There should be a page that lists all of the medals and ribbons that could be granted to a user (excluding commendations, which could arise on the fly and are not set in stone.) This would allow users to review the requirements for earning a specific medal or ribbon, and get to see the art of that award as well, if any.
|
||||
- [ ] **Assignment transfer request system** - Allow users to request transfers to new assignments (i.e. transfer from infantry to aviation, medical to infantry, medical to aviation, infantry to intel, logistics to infantry, etc., covering all cases). The leader of the user's current assignment and the leader of the user's requested assignment should be notified of the request via app and via bot, and should have the option to "approve" or "reject with reason" in either interface. Approvals are only collected from assignments that actually have a leader: if the user's current assignment has no leader (e.g. it lost its leader), only the requested assignment's leader needs to approve — and vice versa if the requested assignment is leaderless. If neither assignment has a leader, skip the leaders entirely and send the request directly to me (or a superuser) for a final call. Once all required approvals are in, the assignment is automatically transferred and updated everywhere. If any required approval is rejected, the rejection reason is sent to the other leader (if one exists) and to the individual making the request, and the individual is given an option to appeal via the app or the bot — this applies even when only one approval was required. If they appeal, all remaining leaders are bypassed and the request goes directly to me (or a superuser) who can then make the final call.
|
||||
- [x] **Ribbon overview and listing page** - There should be a page that lists all of the medals and ribbons that could be granted to a user (excluding commendations, which could arise on the fly and are not set in stone.) This would allow users to review the requirements for earning a specific medal or ribbon, and get to see the art of that award as well, if any.
|
||||
- [x] **Assignment transfer request system** - Allow users to request transfers to new assignments (i.e. transfer from infantry to aviation, medical to infantry, medical to aviation, infantry to intel, logistics to infantry, etc., covering all cases). The leader of the user's current assignment and the leader of the user's requested assignment should be notified of the request via app and via bot, and should have the option to "approve" or "reject with reason" in either interface. Approvals are only collected from assignments that actually have a leader: if the user's current assignment has no leader (e.g. it lost its leader), only the requested assignment's leader needs to approve — and vice versa if the requested assignment is leaderless. If neither assignment has a leader, skip the leaders entirely and send the request directly to me (or a superuser) for a final call. Once all required approvals are in, the assignment is automatically transferred and updated everywhere. If any required approval is rejected, the rejection reason is sent to the other leader (if one exists) and to the individual making the request, and the individual is given an option to appeal via the app or the bot — this applies even when only one approval was required. If they appeal, all remaining leaders are bypassed and the request goes directly to me (or a superuser) who can then make the final call.
|
||||
- [ ] **Scoped admin UI pages** - The admin UI is fantastic, but it needs to be scoped so that users can only see and interact with certain pages even if they have read permissions for any/all pages. For example, if a user has read permissions for the Users collection, they can't just see the admin page for it - they need something like "admin:users:manage" or something like that (based on whatever format fits our current system best.) So long as they have BOTH the read and manage permissions, they can see the admin page. Otherwise, it's just hidden from them entirely.
|
||||
- [ ] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase.
|
||||
- [ ] **"Community" navigation group** - A "Community" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc.
|
||||
- [ ] **"Personnel" navigation group** - A "Personnel" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc.
|
||||
- [ ] **Respawn tickets based on attendance** - I want to track respawn tickets as a "global variable" that is automatically incremented based on reported attendance for a mission compared against real attendance checked via the webapp<->Arma sync system. For every person who marks that they will attend and DOES attend, 1 respawn ticket is awarded. For every person who marks they MIGHT attend and DOES attend, 0.5 respawn tickets will be awarded. For every person who marks that they will NOT attend and does attend, 0.25 respawn tickets will be awarded. All respawn tickets are accumulated and applied to the next operation.
|
||||
|
||||
## Desirable Additions, Medium Priority
|
||||
|
|
|
|||
48
src/app/(frontend)/impersonate/actions.ts
Normal file
48
src/app/(frontend)/impersonate/actions.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({
|
||||
headers: hdrs,
|
||||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
if (!isPayloadUser(user)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
export async function getImpersonationUsers(): Promise<Array<{
|
||||
id: number | string;
|
||||
username?: string | null;
|
||||
displayName?: string | null;
|
||||
}>> {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
if (!(await isSuperuser(payload, user))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await payload.find({
|
||||
collection: "users",
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
pagination: false,
|
||||
sort: "username",
|
||||
select: {
|
||||
username: true,
|
||||
displayName: true,
|
||||
},
|
||||
overrideAccess: true,
|
||||
}).then((result) => result.docs);
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { ImpersonationControl } from "@/components/frontend/impersonation/ImpersonationControl";
|
||||
import { findImpersonationUsers } from "@/lib/impersonation";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function ImpersonatePage() {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false });
|
||||
if (!user || !(await isSuperuser(payload, user))) redirect("/");
|
||||
const users = await findImpersonationUsers(payload);
|
||||
return <main className="flex-1 space-y-6 p-6 md:p-8"><ImpersonationControl users={users.docs as Parameters<typeof ImpersonationControl>[0]["users"]} /></main>;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import type { User } from "@/payload-types";
|
||||
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { requirePermission, hasPermission, isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const UserNotifications: CollectionConfig = {
|
||||
slug: "user-notifications",
|
||||
|
|
@ -15,7 +15,10 @@ export const UserNotifications: CollectionConfig = {
|
|||
read: async ({ req }) => {
|
||||
const user = req.user as User | null;
|
||||
if (!user) return false;
|
||||
if (await hasPermission(req.payload, user, "user-notifications:read")) return true;
|
||||
// Read-all is gated on elevation (not the collection's read permission) so a
|
||||
// mis-granted `user-notifications:read` can never leak other users' docs.
|
||||
if (await isSuperuser(req.payload, user)) return true;
|
||||
if (await hasPermission(req.payload, user, "system:admin-access")) return true;
|
||||
return { user: { equals: user.id } };
|
||||
},
|
||||
create: requirePermission("user-notifications:create"),
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export function BankingOverview({
|
|||
<ActivityFeed transactions={recentTransactions} currencyResource={currencyResource} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts" className="m-0 flex flex-col gap-6">
|
||||
<TabsContent value="accounts" className="m-0 pt-4 flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<LandmarkIcon className="size-4 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -274,15 +274,9 @@ export function AppSidebar({
|
|||
<hr />
|
||||
<NavSecondary
|
||||
items={
|
||||
!adminUrl && !canImpersonate
|
||||
? data.navSecondary
|
||||
: [
|
||||
...data.navSecondary,
|
||||
...(adminUrl ? [{ title: "Admin", url: adminUrl, icon: Shield }] : []),
|
||||
...(canImpersonate
|
||||
? [{ title: "Impersonate", url: "/impersonate", icon: UserCog }]
|
||||
: []),
|
||||
]
|
||||
adminUrl
|
||||
? [...data.navSecondary, { title: "Admin", url: adminUrl, icon: Shield }]
|
||||
: data.navSecondary
|
||||
}
|
||||
className="mt-auto"
|
||||
/>
|
||||
|
|
@ -327,6 +321,7 @@ export function AppSidebar({
|
|||
avatar: "",
|
||||
}}
|
||||
showCallsign={showCallsign}
|
||||
canImpersonate={canImpersonate}
|
||||
/>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { BadgeCheck, Bird, Bomb, ChevronsUpDown, FileTextIcon, LogOut } from "lucide-react";
|
||||
import { BadgeCheck, Bird, Bomb, ChevronsUpDown, FileTextIcon, LogOut, Users2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
@ -21,10 +21,14 @@ import {
|
|||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import type { User } from "@/payload-types";
|
||||
import { ImpersonationModal } from "@/components/frontend/impersonation/ImpersonationModal";
|
||||
import { getImpersonationUsers } from "@/app/(frontend)/impersonate/actions";
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
showCallsign = false,
|
||||
canImpersonate = false,
|
||||
}: {
|
||||
user: {
|
||||
displayName: string;
|
||||
|
|
@ -32,10 +36,14 @@ export function NavUser({
|
|||
avatar: string;
|
||||
};
|
||||
showCallsign?: boolean;
|
||||
canImpersonate?: boolean;
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const router = useRouter();
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const [showImpersonationModal, setShowImpersonationModal] = useState(false);
|
||||
const [impersonationUsers, setImpersonationUsers] = useState<any[]>([]);
|
||||
const [loadingImpersonationUsers, setLoadingImpersonationUsers] = useState(false);
|
||||
|
||||
const primaryLine = showCallsign ? user.username : user.displayName;
|
||||
const secondaryLine = showCallsign ? user.displayName : user.username;
|
||||
|
|
@ -53,6 +61,24 @@ export function NavUser({
|
|||
router.refresh();
|
||||
};
|
||||
|
||||
const handleOpenImpersonationModal = async () => {
|
||||
setLoadingImpersonationUsers(true);
|
||||
try {
|
||||
const users = await getImpersonationUsers();
|
||||
setImpersonationUsers(users);
|
||||
} catch (error) {
|
||||
console.error("Failed to load impersonation users:", error);
|
||||
} finally {
|
||||
setLoadingImpersonationUsers(false);
|
||||
}
|
||||
setShowImpersonationModal(true);
|
||||
};
|
||||
|
||||
const handleImpersonationStarted = async () => {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
|
|
@ -122,10 +148,12 @@ export function NavUser({
|
|||
Account
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* <Bell /> */}
|
||||
{/* Notifications */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{canImpersonate && (
|
||||
<DropdownMenuItem onClick={handleOpenImpersonationModal}>
|
||||
<Users2 />
|
||||
Impersonate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => handleLogout()}>
|
||||
|
|
@ -135,6 +163,12 @@ export function NavUser({
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<ImpersonationModal
|
||||
open={showImpersonationModal}
|
||||
onOpenChange={setShowImpersonationModal}
|
||||
users={impersonationUsers}
|
||||
onStart={handleImpersonationStarted}
|
||||
/>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
type Target = { id: number | string; username?: string | null; displayName?: string | null };
|
||||
|
||||
export function ImpersonationControl({ users }: { users: Target[] }) {
|
||||
const router = useRouter();
|
||||
const [userId, setUserId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function start() {
|
||||
if (!userId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/impersonation/start", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(body?.error ?? "Unable to start impersonation.");
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unable to start impersonation.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold">Impersonate a user</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">All requests will use the selected user's identity and permissions until you return to your account.</p>
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<Label htmlFor="impersonation-user">User</Label>
|
||||
<Select value={userId} onValueChange={setUserId}>
|
||||
<SelectTrigger id="impersonation-user"><SelectValue placeholder="Select a user" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((user) => <SelectItem key={String(user.id)} value={String(user.id)}>{user.displayName || user.username || `User ${user.id}`} {user.username ? `(${user.username})` : ""}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
|
||||
<Button className="mt-6" onClick={start} disabled={!userId || busy}>{busy ? "Starting..." : "Start impersonation"}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/components/frontend/impersonation/ImpersonationModal.tsx
Normal file
127
src/components/frontend/impersonation/ImpersonationModal.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import type { ImpersonationUser } from "@/lib/impersonation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImpersonationModalProps {
|
||||
users: ImpersonationUser[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onStart?: () => void;
|
||||
}
|
||||
|
||||
export function ImpersonationModal({ users, open, onOpenChange, onStart }: ImpersonationModalProps) {
|
||||
const [userId, setUserId] = useState("");
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedUser = useMemo(
|
||||
() => users.find((user) => String(user.id) === userId) ?? null,
|
||||
[users, userId],
|
||||
);
|
||||
|
||||
async function startImpersonation() {
|
||||
if (!userId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/impersonation/start", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(body?.error ?? "Unable to start impersonation.");
|
||||
onOpenChange(false);
|
||||
onStart?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unable to start impersonation.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Impersonate a user</DialogTitle>
|
||||
<DialogDescription>
|
||||
All requests will use the selected user's identity and permissions until you return to your account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="impersonation-user">User</Label>
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="impersonation-user"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={pickerOpen}
|
||||
className="mt-1 w-full justify-between font-normal"
|
||||
>
|
||||
{selectedUser
|
||||
? selectedUser.displayName || selectedUser.username || `User ${selectedUser.id}`
|
||||
: "Select a user"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search by name or username..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No users found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={String(user.id)}
|
||||
value={`${user.displayName ?? ""} ${user.username ?? ""}`.trim()}
|
||||
onSelect={() => {
|
||||
setUserId(String(user.id));
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{user.displayName || user.username || `User ${user.id}`}
|
||||
{user.username ? ` (${user.username})` : ""}
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto size-4",
|
||||
String(user.id) === userId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button onClick={startImpersonation} disabled={!userId || busy}>
|
||||
{busy ? "Starting..." : "Start impersonation"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -244,7 +244,7 @@ export function LockerView({
|
|||
<div className="grid min-w-0 items-start gap-4 xl:grid-cols-[minmax(24rem,30rem)_minmax(0,1fr)]">
|
||||
<LockerCharacterPanel loadouts={loadouts} items={items} />
|
||||
|
||||
<div className="grid min-w-0 items-start gap-3 2xl:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="grid min-w-0 items-start gap-3 2xl:grid-cols-[minmax(0,1fr)_18rem] sticky top-5">
|
||||
<div className="min-w-0">
|
||||
{items.length === 0 ? (
|
||||
<Item variant="outline" className="rounded-sm border border-border/30 bg-card/40">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { BellIcon } from "lucide-react";
|
||||
import { BellIcon, CheckIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -83,26 +83,28 @@ export function NotificationsBell() {
|
|||
};
|
||||
}, [fetchNotifications]);
|
||||
|
||||
async function handleItemClick(item: NotificationItem) {
|
||||
if (marking) return;
|
||||
if (!item.read) {
|
||||
setMarking(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await markNotificationsRead([item.id]);
|
||||
if (result.success) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)),
|
||||
);
|
||||
} else {
|
||||
setActionError(result.error ?? "Failed to update notifications.");
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to update notifications.");
|
||||
} finally {
|
||||
setMarking(false);
|
||||
async function handleMarkOne(item: NotificationItem) {
|
||||
if (item.read || marking) return;
|
||||
setMarking(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await markNotificationsRead([item.id]);
|
||||
if (result.success) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)),
|
||||
);
|
||||
} else {
|
||||
setActionError(result.error ?? "Failed to update notifications.");
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to update notifications.");
|
||||
} finally {
|
||||
setMarking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleItemClick(item: NotificationItem) {
|
||||
if (!item.read) await handleMarkOne(item);
|
||||
if (item.link) {
|
||||
router.push(item.link);
|
||||
setOpen(false);
|
||||
|
|
@ -194,8 +196,24 @@ export function NotificationsBell() {
|
|||
)}
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{timeAgo(item.createdAt)}
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{!item.read && (
|
||||
<button
|
||||
type="button"
|
||||
title="Mark as read"
|
||||
aria-label={`Mark "${item.title}" as read`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleMarkOne(item);
|
||||
}}
|
||||
className="rounded-sm p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{timeAgo(item.createdAt)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{item.message && (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ const USER_PERMISSIONS: Permission[] = [
|
|||
"qualifications:read",
|
||||
"assignments:read",
|
||||
"experience:read",
|
||||
"user-notifications:read",
|
||||
"missions:read",
|
||||
"mission-attendances:read",
|
||||
"campaigns:read",
|
||||
|
|
|
|||
125
tests/int/notification-access.int.spec.ts
Normal file
125
tests/int/notification-access.int.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Role, User } from "@/payload-types";
|
||||
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `ntf-${Date.now().toString(36)}`;
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
describe("User notification read access control", () => {
|
||||
const roleIds: number[] = [];
|
||||
const userIds: number[] = [];
|
||||
|
||||
let plainUser: User;
|
||||
let adminUser: User;
|
||||
let devUser: User;
|
||||
let leakyUser: User;
|
||||
|
||||
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
|
||||
const role = (await payload.create({
|
||||
collection: "roles",
|
||||
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Role;
|
||||
roleIds.push(role.id);
|
||||
return role;
|
||||
};
|
||||
|
||||
const makeUser = async (label: string, roleId: number): Promise<User> => {
|
||||
const user = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-${label}`,
|
||||
discordUsername: `${RUN}-${label}`,
|
||||
displayName: label.toUpperCase(),
|
||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||
password: "Test123",
|
||||
// Permission resolution reads roleDocs (the dynamic RBAC relationship), not
|
||||
// the legacy `roles` enum — so assign a real role doc to grant permissions.
|
||||
roleDocs: [roleId],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
userIds.push(user.id);
|
||||
return user;
|
||||
};
|
||||
|
||||
// Invoke the collection's read access control exactly as Payload would for an
|
||||
// authenticated request. The vitest environment has no Next.js HTTP server, so the
|
||||
// REST endpoint (/api/user-notifications) is not reachable — calling the access
|
||||
// function directly is the standard way to unit-test Payload access control.
|
||||
const readDecision = async (user: User | null): Promise<unknown> => {
|
||||
const fn = UserNotifications.access?.read;
|
||||
expect(typeof fn).toBe("function");
|
||||
return await (fn as (args: { req: unknown }) => Promise<unknown>)({ req: { user, payload } });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
invalidatePermissionCache();
|
||||
|
||||
const plainRole = await makeRole("plain", { permissions: [] });
|
||||
const adminRole = await makeRole("admin", { permissions: ["system:admin-access"] });
|
||||
const devRole = await makeRole("dev", { isSuperuser: true });
|
||||
// A role carrying the collection's own read permission. Before the fix, holding
|
||||
// `user-notifications:read` alone granted read-all; after the fix it must stay scoped.
|
||||
const leakyRole = await makeRole("leaky", { permissions: ["user-notifications:read"] });
|
||||
|
||||
plainUser = await makeUser("plain", plainRole.id);
|
||||
adminUser = await makeUser("admin", adminRole.id);
|
||||
devUser = await makeUser("dev", devRole.id);
|
||||
leakyUser = await makeUser("leaky", leakyRole.id);
|
||||
}, TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
for (const id of userIds) {
|
||||
const profiles = await payload
|
||||
.find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
for (const p of profiles?.docs ?? []) {
|
||||
await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of roleIds) {
|
||||
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes a regular user's reads to their own notifications", async () => {
|
||||
const decision = await readDecision(plainUser);
|
||||
// The bug: this returned `true` (read-all), leaking every user's notifications.
|
||||
expect(decision).not.toBe(true);
|
||||
expect(decision).toMatchObject({ user: { equals: plainUser.id } });
|
||||
}, TIMEOUT);
|
||||
|
||||
it("grants read-all to admin elevation (system:admin-access)", async () => {
|
||||
expect(await readDecision(adminUser)).toBe(true);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("grants read-all to superuser roles", async () => {
|
||||
expect(await readDecision(devUser)).toBe(true);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("denies anonymous (no user) reads", async () => {
|
||||
expect(await readDecision(null)).toBe(false);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("does not treat the collection's own read permission as read-all", async () => {
|
||||
// Defense in depth: even a role holding `user-notifications:read` must stay scoped,
|
||||
// because read-all is now gated on elevation only.
|
||||
const decision = await readDecision(leakyUser);
|
||||
expect(decision).not.toBe(true);
|
||||
expect(decision).toMatchObject({ user: { equals: leakyUser.id } });
|
||||
}, TIMEOUT);
|
||||
});
|
||||
Loading…
Reference in a new issue