diff --git a/src/app/(frontend)/account/actions.ts b/src/app/(frontend)/account/actions.ts new file mode 100644 index 0000000..523e8b7 --- /dev/null +++ b/src/app/(frontend)/account/actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/app/(frontend)/account/page.tsx b/src/app/(frontend)/account/page.tsx new file mode 100644 index 0000000..0ca778e --- /dev/null +++ b/src/app/(frontend)/account/page.tsx @@ -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 ( +
+
+
+ +

Account

+
+

+ Manage your profile, password, and notification preferences. +

+
+ +
+ + + Profile + How your name appears across the platform. + + + + + + + + + Password + + Change your password. You will stay logged in on this device. + + + + + + + + + + Discord Username + + Your Discord username is managed by leadership. Request a change here. + + + + + + + + + + Preferences + Control in-app notifications and display options. + + + + + +
+
+ ); +} diff --git a/src/components/frontend/account/AccountProfileForm.tsx b/src/components/frontend/account/AccountProfileForm.tsx new file mode 100644 index 0000000..38e514b --- /dev/null +++ b/src/components/frontend/account/AccountProfileForm.tsx @@ -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(null); + const [success, setSuccess] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + 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 ( +
+
+ + setValue(e.target.value)} + maxLength={60} + required + /> +
+ {error &&

{error}

} + {success &&

{success}

} +
+ +
+
+ ); +} diff --git a/src/components/frontend/account/ChangePasswordForm.tsx b/src/components/frontend/account/ChangePasswordForm.tsx new file mode 100644 index 0000000..b0cbe38 --- /dev/null +++ b/src/components/frontend/account/ChangePasswordForm.tsx @@ -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(null); + const [success, setSuccess] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + 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 ( +
+
+ + setCurrentPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+
+ + setNewPassword(e.target.value)} + autoComplete="new-password" + required + /> +
+
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + required + /> +
+ {error &&

{error}

} + {success &&

{success}

} +
+ +
+
+ ); +} diff --git a/src/components/frontend/account/DiscordUsernameRequestForm.tsx b/src/components/frontend/account/DiscordUsernameRequestForm.tsx new file mode 100644 index 0000000..91a58b9 --- /dev/null +++ b/src/components/frontend/account/DiscordUsernameRequestForm.tsx @@ -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(null); + const [success, setSuccess] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + 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 ( +
+
+ +

{currentDiscordUsername || "Not set"}

+
+
+ + setValue(e.target.value)} + maxLength={32} + required + /> +

+ Leadership will be notified to approve this change. +

+
+ {error &&

{error}

} + {success &&

{success}

} +
+ +
+
+ ); +} diff --git a/src/components/frontend/account/PreferencesForm.tsx b/src/components/frontend/account/PreferencesForm.tsx new file mode 100644 index 0000000..45cf3d2 --- /dev/null +++ b/src/components/frontend/account/PreferencesForm.tsx @@ -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(initialMutedTypes); + const [showCallsign, setShowCallsign] = useState(initialShowCallsign); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(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) => { + 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 ( +
+
+

Notifications

+ +
+ {MUTEABLE_NOTIFICATION_TYPES.map((t) => ( + + ))} +
+
+ +
+

Display

+ +
+ + {error &&

{error}

} + {success &&

{success}

} +
+ +
+
+ ); +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..e4d883e --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,75 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index f64bb2d..b6a3bee 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -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];