1
0
Fork 0

feat(account): add self-service account page with profile, password, Discord request, and preferences

- Add /account page backed by server actions
- Allow editing display name, changing password, and requesting a Discord username change
- Add notification muting and callsign display preferences UI
- Add shadcn card component and account event types
This commit is contained in:
Jason Fraley 2026-08-11 23:25:21 -04:00
parent b8cbb85dc8
commit c81036bd82
8 changed files with 764 additions and 0 deletions

View file

@ -0,0 +1,234 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { notifyUser } from "@/lib/notifications";
import {
MUTEABLE_NOTIFICATION_VALUES,
type MuteableNotificationType,
} from "@/lib/notifications/notificationTypes";
export interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!user) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function changePassword(input: {
currentPassword: string;
newPassword: string;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const userId = user.id as number;
if (!input.currentPassword || !input.newPassword) {
return { success: false, error: "Current and new password are required." };
}
if (input.newPassword.length < 8) {
return { success: false, error: "New password must be at least 8 characters." };
}
try {
await payload.login({
collection: "users",
data: { username: user.username, password: input.currentPassword },
});
} catch {
return { success: false, error: "Current password is incorrect." };
}
await payload.update({
collection: "users",
id: userId,
data: { password: input.newPassword },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.AccountPasswordChange,
message: "Password changed",
actor: userId,
targetCollection: "users",
targetId: userId,
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function updateDisplayName(input: { displayName: string }): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const userId = user.id as number;
const trimmed = input.displayName.trim();
if (!trimmed) {
return { success: false, error: "Display name is required." };
}
if (trimmed.length > 60) {
return { success: false, error: "Display name must be 60 characters or fewer." };
}
await payload.update({
collection: "users",
id: userId,
data: { displayName: trimmed },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.AccountProfileUpdate,
message: `Updated display name to "${trimmed}"`,
actor: userId,
targetCollection: "users",
targetId: userId,
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function requestDiscordUsernameChange(input: {
desiredDiscordUsername: string;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const userId = user.id as number;
const trimmed = input.desiredDiscordUsername.trim();
if (!trimmed) {
return { success: false, error: "Discord username is required." };
}
if (trimmed.length < 2 || trimmed.length > 32) {
return { success: false, error: "Discord usernames must be between 2 and 32 characters." };
}
if (/\s/.test(trimmed)) {
return { success: false, error: "Discord usernames cannot contain spaces." };
}
const existingRes = await payload.find({
collection: "users",
where: { discordUsername: { equals: trimmed } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (existingRes.docs.some((doc) => doc.id !== userId)) {
return { success: false, error: "That Discord username is already in use." };
}
const staffRes = await payload.find({
collection: "users",
where: {
or: [{ roles: { contains: "admin" } }, { roles: { contains: "developer" } }],
},
limit: 100,
depth: 0,
overrideAccess: true,
});
const staffIds = [...new Set(staffRes.docs.map((doc) => doc.id as number))].filter(
(id) => id !== userId,
);
for (const staffId of staffIds) {
await notifyUser(payload, {
userId: staffId,
type: "account:discord-request",
title: `Discord username change requested by ${user.username}`,
message: `wants to change their Discord username to "${trimmed}".`,
link: `/admin/collections/users/${userId}`,
});
}
await notifyUser(payload, {
userId,
type: "account:discord-request",
title: "Request submitted",
message: `Your request to change your Discord username to "${trimmed}" has been sent to leadership.`,
});
await emitGameEvent(payload, {
type: EventTypes.AccountDiscordRequest,
message: `Requested a Discord username change to "${trimmed}"`,
actor: userId,
targetCollection: "users",
targetId: userId,
data: { requestedDiscordUsername: trimmed },
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function updatePreferences(input: {
mutedAll: boolean;
mutedTypes: string[];
showCallsign: boolean;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const userId = user.id as number;
const mutedTypes = input.mutedTypes.filter((t): t is MuteableNotificationType =>
(MUTEABLE_NOTIFICATION_VALUES as readonly string[]).includes(t),
);
await payload.update({
collection: "users",
id: userId,
data: {
preferences: {
notifications: { mutedAll: input.mutedAll, mutedTypes },
display: { showCallsign: input.showCallsign },
},
},
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.AccountPreferencesUpdate,
message: "Updated preferences",
actor: userId,
targetCollection: "users",
targetId: userId,
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}

View file

@ -0,0 +1,107 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { UserCogIcon } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { AccountProfileForm } from "@/components/frontend/account/AccountProfileForm";
import { ChangePasswordForm } from "@/components/frontend/account/ChangePasswordForm";
import { DiscordUsernameRequestForm } from "@/components/frontend/account/DiscordUsernameRequestForm";
import { PreferencesForm } from "@/components/frontend/account/PreferencesForm";
export const metadata = {
title: "Account — Polaris Task Force",
};
export default async function AccountPage() {
const payload = await getPayload({ config });
const { user } = await payload.auth({
headers: await nextHeaders(),
canSetHeaders: false,
});
if (!user) {
redirect("/");
}
const fullUser = await payload.findByID({
collection: "users",
id: user.id,
depth: 0,
overrideAccess: true,
});
const notifications = fullUser.preferences?.notifications;
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<UserCogIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Account</h1>
</div>
<p className="text-sm text-muted-foreground">
Manage your profile, password, and notification preferences.
</p>
</div>
<div className="flex max-w-2xl flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>How your name appears across the platform.</CardDescription>
</CardHeader>
<CardContent>
<AccountProfileForm displayName={fullUser.displayName ?? ""} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>
Change your password. You will stay logged in on this device.
</CardDescription>
</CardHeader>
<CardContent>
<ChangePasswordForm />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Discord Username</CardTitle>
<CardDescription>
Your Discord username is managed by leadership. Request a change here.
</CardDescription>
</CardHeader>
<CardContent>
<DiscordUsernameRequestForm
currentDiscordUsername={fullUser.discordUsername ?? ""}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardDescription>Control in-app notifications and display options.</CardDescription>
</CardHeader>
<CardContent>
<PreferencesForm
mutedAll={notifications?.mutedAll ?? false}
mutedTypes={notifications?.mutedTypes ?? []}
showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
/>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateDisplayName } from "@/app/(frontend)/account/actions";
export function AccountProfileForm({ displayName }: { displayName: string }) {
const router = useRouter();
const [value, setValue] = useState(displayName);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(null);
try {
const res = await updateDisplayName({ displayName: value });
if (!res.success) {
setError(res.error ?? "Failed to update display name.");
return;
}
setSuccess("Display name updated.");
router.refresh();
} catch {
setError("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="displayName">In-Game Name</Label>
<Input
id="displayName"
name="displayName"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={60}
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-emerald-500">{success}</p>}
<div>
<Button type="submit" disabled={loading}>
{loading && <Loader2Icon className="size-4 animate-spin" />}
{loading ? "Saving..." : "Save"}
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { changePassword } from "@/app/(frontend)/account/actions";
export function ChangePasswordForm() {
const router = useRouter();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
setSuccess(null);
if (newPassword !== confirmPassword) {
setError("New passwords do not match.");
return;
}
setLoading(true);
try {
const res = await changePassword({ currentPassword, newPassword });
if (!res.success) {
setError(res.error ?? "Failed to change password.");
return;
}
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setSuccess("Password updated.");
router.refresh();
} catch {
setError("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="currentPassword">Current password</Label>
<Input
id="currentPassword"
name="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="newPassword">New password</Label>
<Input
id="newPassword"
name="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirmPassword">Confirm new password</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-emerald-500">{success}</p>}
<div>
<Button type="submit" disabled={loading}>
{loading && <Loader2Icon className="size-4 animate-spin" />}
{loading ? "Updating..." : "Change password"}
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { requestDiscordUsernameChange } from "@/app/(frontend)/account/actions";
export function DiscordUsernameRequestForm({
currentDiscordUsername,
}: {
currentDiscordUsername: string;
}) {
const router = useRouter();
const [value, setValue] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(null);
try {
const res = await requestDiscordUsernameChange({ desiredDiscordUsername: value });
if (!res.success) {
setError(res.error ?? "Failed to submit request.");
return;
}
setValue("");
setSuccess("Request submitted — leadership has been notified.");
router.refresh();
} catch {
setError("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Current Discord username</Label>
<p className="text-sm text-muted-foreground">{currentDiscordUsername || "Not set"}</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="desiredDiscordUsername">New Discord username</Label>
<Input
id="desiredDiscordUsername"
name="desiredDiscordUsername"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={32}
required
/>
<p className="text-xs text-muted-foreground">
Leadership will be notified to approve this change.
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-emerald-500">{success}</p>}
<div>
<Button type="submit" disabled={loading}>
{loading && <Loader2Icon className="size-4 animate-spin" />}
{loading ? "Submitting..." : "Request change"}
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
import { updatePreferences } from "@/app/(frontend)/account/actions";
interface PreferencesFormProps {
mutedAll: boolean;
mutedTypes: string[];
showCallsign: boolean;
}
export function PreferencesForm({
mutedAll: initialMutedAll,
mutedTypes: initialMutedTypes,
showCallsign: initialShowCallsign,
}: PreferencesFormProps) {
const router = useRouter();
const [mutedAll, setMutedAll] = useState(initialMutedAll);
const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes);
const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const toggleType = (value: string, checked: boolean) => {
setMutedTypes((prev) =>
checked ? [...new Set([...prev, value])] : prev.filter((t) => t !== value),
);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(null);
try {
const res = await updatePreferences({ mutedAll, mutedTypes, showCallsign });
if (!res.success) {
setError(res.error ?? "Failed to save preferences.");
return;
}
setSuccess("Preferences saved.");
router.refresh();
} catch {
setError("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Notifications</h3>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={mutedAll}
onCheckedChange={(c) => setMutedAll(c === true)}
/>
Mute all in-app notifications
</label>
<div
className={
mutedAll
? "flex flex-col gap-3 pl-6 opacity-50 pointer-events-none"
: "flex flex-col gap-3 pl-6"
}
>
{MUTEABLE_NOTIFICATION_TYPES.map((t) => (
<label key={t.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={mutedTypes.includes(t.value)}
onCheckedChange={(c) => toggleType(t.value, c === true)}
/>
{t.label}
</label>
))}
</div>
</div>
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Display</h3>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={showCallsign}
onCheckedChange={(c) => setShowCallsign(c === true)}
/>
Show callsign (username) instead of display name
</label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-emerald-500">{success}</p>}
<div>
<Button type="submit" disabled={loading}>
{loading && <Loader2Icon className="size-4 animate-spin" />}
{loading ? "Saving..." : "Save preferences"}
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View file

@ -75,6 +75,12 @@ export const EventTypes = {
// NPCs
NpcGenerate: "npc:generate",
// Account
AccountPasswordChange: "account:password-change",
AccountProfileUpdate: "account:profile-update",
AccountDiscordRequest: "account:discord-request",
AccountPreferencesUpdate: "account:preferences-update",
} as const;
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];