diff --git a/app/src/app/profile/edit/page.tsx b/app/src/app/profile/edit/page.tsx index 05eb3ea3..2871a14c 100644 --- a/app/src/app/profile/edit/page.tsx +++ b/app/src/app/profile/edit/page.tsx @@ -75,6 +75,11 @@ import UserRequestSystem from "@/layout/UserRequestSystem"; import type { Gender } from "@/validators/register"; import type { BaseServerResponse } from "@/server/api/trpc"; import type { Bloodline, Village } from "@/drizzle/schema"; +import { + AiRule, + ConditionDistanceHigherThan, + ActionMoveTowardsOpponent, +} from "@/validators/ai"; export default function EditProfile() { // State @@ -293,6 +298,17 @@ const BattleSettingsEdit: React.FC<{ userId: string }> = ({ userId }) => { }, }); + const { mutate: updateAiProfile, isPending } = + api.ai.updateAiProfile.useMutation({ + onSuccess: async (data) => { + showMutationToast(data); + if (data.success) { + await utils.profile.getAi.invalidate(); + await utils.profile.getPublicUser.invalidate(); + } + }, + }); + // Update highest preferences const { mutate: updatePreferences } = api.profile.updatePreferences.useMutation({ onSuccess: async (data) => { @@ -445,6 +461,37 @@ const BattleSettingsEdit: React.FC<{ userId: string }> = ({ userId }) => { } /> + < br/> + < br/> + + {isPending ? : "Reset AI Profile"} + + } + onAccept={() => { + if (!profile?.aiProfileId) return; + const defaultAiProfilePayload = { + id: profile.aiProfileId, + rules: [ + AiRule.parse({ + conditions: [ConditionDistanceHigherThan.parse({ value: 2 })], + action: ActionMoveTowardsOpponent.parse({}), + }), + ], + includeDefaultRules: true, + }; + updateAiProfile(defaultAiProfilePayload); + }} + > + This will reset your AI profile to default settings. This action cannot be undone. Are you sure you want to continue? + + diff --git a/app/src/layout/Clan.tsx b/app/src/layout/Clan.tsx index 20039b82..5057ea2c 100644 --- a/app/src/layout/Clan.tsx +++ b/app/src/layout/Clan.tsx @@ -67,6 +67,7 @@ import type { MutateContentSchema } from "@/validators/comments"; import type { UserNindo } from "@/drizzle/schema"; import type { ArrayElement } from "@/utils/typeutils"; import type { ClanRouter } from "@/routers/clan"; +import { canEditClans } from "@/utils/permissions"; export const ClansOverview: React.FC = () => { // Must be in allied village @@ -1209,73 +1210,79 @@ export const ClanMembers: React.FC = (props) => { // Derived const isColeader = checkCoLeader(userId, clanData); const isLeader = userId === clanData.leaderId; + const canEdit = userData ? canEditClans(userData.role) : false; // Adjust members for table - const members = clanData.members - .map((member) => { - const memberIsLeader = member.userId === clanData.leaderId; - const memberIsColeader = checkCoLeader(member.userId, clanData); - const memberLeaderLike = memberIsLeader || memberIsColeader; - return { - ...member, - rank: memberIsLeader - ? "Leader" - : memberIsColeader - ? "Coleader" - : showUserRank(member), - actions: ( -
- {member.userId !== userId && ( - <> - {(isLeader || isColeader) && !memberLeaderLike && ( - - - Kick - - } - onAccept={() => kick({ clanId, memberId: member.userId })} - > - Confirm that you want to kick this member from the clan. - - )} - {isLeader && memberLeaderLike && ( - - - Demote - - } - onAccept={() => demote({ clanId, memberId: member.userId })} - > - Confirm that you want to demote this member. - - )} - {(isLeader || (isColeader && !memberLeaderLike)) && ( - - - Promote - - } - onAccept={() => promote({ clanId, memberId: member.userId })} - > - Confirm that you want to promote this member to leader of the clan. - - )} - - )} -
- ), - }; - }) + const members = clanData.members.map((member) => { + const memberIsLeader = member.userId === clanData.leaderId; + const memberIsColeader = checkCoLeader(member.userId, clanData); + const canKick = + canEdit || // canEdit role can kick anyone + (isLeader && !memberIsLeader) || // Leader can kick anyone except other leaders + (isColeader && !memberIsLeader && !memberIsColeader); // Co-leaders can kick normal members only + return { + ...member, + rank: memberIsLeader ? "Leader" : memberIsColeader ? "Coleader" : showUserRank(member), + actions: ( +
+ {member.userId !== userId && ( + <> + {/* KICK BUTTON (Now allows kicking leaders if canEdit is true) */} + {canKick && ( + + + Kick + + } + onAccept={() => kick({ clanId, memberId: member.userId })} + > + {memberIsLeader + ? "You are about to kick the leader. Ensure leadership transition is planned." + : "Confirm that you want to kick this member from the clan."} + + )} + + {/* DEMOTE BUTTON */} + {(isLeader || canEdit) && ( + + + Demote + + } + onAccept={() => demote({ clanId, memberId: member.userId })} + > + Confirm that you want to demote this member. + + )} + + {/* PROMOTE BUTTON */} + {(isLeader || (isColeader && !memberIsLeader && !memberIsColeader) || canEdit) && ( + + + Promote + + } + onAccept={() => promote({ clanId, memberId: member.userId })} + > + Confirm that you want to promote this member. + + )} + + )} +
+ ), + }; + }) .sort((a, b) => { if (a.rank === "Leader") return -1; if (b.rank === "Leader") return 1; diff --git a/app/src/server/api/routers/clan.ts b/app/src/server/api/routers/clan.ts index e6ff9d1b..9961500a 100644 --- a/app/src/server/api/routers/clan.ts +++ b/app/src/server/api/routers/clan.ts @@ -12,6 +12,7 @@ import { fetchUser, updateNindo } from "@/routers/profile"; import { getServerPusher } from "@/libs/pusher"; import { clanCreateSchema, checkCoLeader } from "@/validators/clan"; import { hasRequiredRank } from "@/libs/train"; +import { canEditClans } from "@/utils/permissions"; import { checkIfSectorIsAvailable } from "@/libs/clan"; import { fetchRequest, @@ -553,39 +554,58 @@ export const clanRouter = createTRPCRouter({ const isLeader = user.userId === fetchedClan?.leaderId; const isColeader = checkCoLeader(user.userId, fetchedClan); const isMemberColeader = checkCoLeader(input.memberId, fetchedClan); - const updateData = (() => { - if (isMemberColeader && isLeader) - return { - leaderId: input.memberId, - coLeader1: - fetchedClan.coLeader1 === input.memberId ? null : fetchedClan.coLeader1, - coLeader2: - fetchedClan.coLeader2 === input.memberId ? null : fetchedClan.coLeader2, - coLeader3: - fetchedClan.coLeader3 === input.memberId ? null : fetchedClan.coLeader3, - coLeader4: - fetchedClan.coLeader4 === input.memberId ? null : fetchedClan.coLeader4, - }; - if (!fetchedClan?.coLeader1) return { coLeader1: input.memberId }; - if (!fetchedClan?.coLeader2) return { coLeader2: input.memberId }; - if (!fetchedClan?.coLeader3) return { coLeader3: input.memberId }; - if (!fetchedClan?.coLeader4) return { coLeader4: input.memberId }; - return null; - })(); + const canEdit = canEditClans(user.role); + const isMemberLeader = input.memberId === fetchedClan?.leaderId; // Guards if (!user) return errorResponse("User not found"); if (!member) return errorResponse("Member not found"); - if (!isLeader && !isColeader) + if (member.userId === user.userId) return errorResponse("Not yourself"); + if (isMemberLeader) + return errorResponse(`${groupLabel} leader cannot be promoted`); + if (!isLeader && !isColeader && !canEdit) return errorResponse(`Only ${groupLabel} leaders can promote`); - if (isMemberColeader && !isLeader) + if (isMemberColeader && !isLeader && !canEdit) return errorResponse(`Only for ${groupLabel} leader`); - if (member.userId === user.userId) return errorResponse("Not yourself"); if (member.clanId !== fetchedClan.id) return errorResponse(`Not in ${groupLabel}`); - if (!updateData) - return errorResponse(`No more co-leaders can be added to ${groupLabel}`); - if (!hasRequiredRank(member.rank, CLAN_RANK_REQUIREMENT)) { + if (!hasRequiredRank(member.rank, CLAN_RANK_REQUIREMENT)) return errorResponse("Leader rank too low"); + let updateData = null; + // If member is a co-leader and the user is either a leader or has canEdit, promote them to leader + if (isMemberColeader && (isLeader || canEdit)) { + updateData = { + leaderId: input.memberId, // Promote co-leader to leader + coLeader1: + fetchedClan.coLeader1 === input.memberId + ? fetchedClan.leaderId + : fetchedClan.coLeader1, // Demote leader to first available co-leader slot + coLeader2: + fetchedClan.coLeader2 === input.memberId + ? fetchedClan.coLeader1 + : fetchedClan.coLeader2, + coLeader3: + fetchedClan.coLeader3 === input.memberId + ? fetchedClan.coLeader2 + : fetchedClan.coLeader3, + coLeader4: + fetchedClan.coLeader4 === input.memberId + ? fetchedClan.coLeader3 + : fetchedClan.coLeader4, + }; + } + // If member is NOT a co-leader, allow promotion to co-leader instead + else if (!isMemberColeader) { + if (!fetchedClan.coLeader1) updateData = { coLeader1: input.memberId }; + else if (!fetchedClan.coLeader2) updateData = { coLeader2: input.memberId }; + else if (!fetchedClan.coLeader3) updateData = { coLeader3: input.memberId }; + else if (!fetchedClan.coLeader4) updateData = { coLeader4: input.memberId }; + else return errorResponse(`No more co-leader slots available in ${groupLabel}`); + } + // If the member is already a co-leader but the user isn't a leader or can't edit, deny the promotion attempt + else { + return errorResponse( + `Only ${groupLabel} leader or canEdit users can promote to leader`, + ); } // Mutate await ctx.drizzle.update(clan).set(updateData).where(eq(clan.id, fetchedClan.id)); @@ -607,22 +627,19 @@ export const clanRouter = createTRPCRouter({ const isColeader = checkCoLeader(user.userId, clanData); const isMemberLeader = input.memberId === clanData?.leaderId; const isMemberColeader = checkCoLeader(input.memberId, clanData); + const canEdit = canEditClans(user.role); const isYourself = ctx.userId === input.memberId; const groupLabel = user?.isOutlaw ? "faction" : "clan"; // Guards if (!clanData) return errorResponse(`${groupLabel} not found`); if (!user) return errorResponse("User not found"); if (!member) return errorResponse("Member not found"); - if (!isLeader && !isColeader) - return errorResponse(`Only ${groupLabel} leaders can demote`); + if (!isLeader && !isColeader && !canEdit) return errorResponse(`Not allowed`); if (isMemberLeader) return errorResponse(`New ${groupLabel} leader must be promoted first`); if (member.clanId !== clanData.id) return errorResponse(`Not in ${groupLabel}`); - if (!hasRequiredRank(member.rank, CLAN_RANK_REQUIREMENT)) { - return errorResponse("Leader rank too low"); - } - if (isMemberColeader && !isLeader && !isYourself) { - return errorResponse(`Only for ${groupLabel} leader`); + if (isMemberColeader && !isLeader && !canEdit && !isYourself) { + return errorResponse(`Only ${groupLabel} leader can demote`); } // Mutate await ctx.drizzle @@ -652,17 +669,28 @@ export const clanRouter = createTRPCRouter({ const isColeader = checkCoLeader(user.userId, fetchedClan); const isMemberColeader = checkCoLeader(input.memberId, fetchedClan); const isMemberLeader = input.memberId === fetchedClan?.leaderId; + const canEdit = canEditClans(user.role); const groupLabel = user?.isOutlaw ? "faction" : "clan"; // Guards if (!fetchedClan) return errorResponse(`${groupLabel} not found`); if (!user) return errorResponse("User not found"); if (!member) return errorResponse("Member not found"); - if (isMemberLeader) return errorResponse(`Cannot kick ${groupLabel} leader`); + if (isMemberLeader && !canEdit) + return errorResponse(`Cannot kick ${groupLabel} leader`); if (fetchedClan.villageId !== user.villageId) return errorResponse(user.isOutlaw ? "!= syndicate" : "!= village"); - if (!isLeader && !isColeader) return errorResponse("Not allowed"); - if (!isLeader && isMemberColeader) + if (!isLeader && !isColeader && !canEdit) return errorResponse("Not allowed"); + if (!isLeader && isMemberColeader && !canEdit) return errorResponse(`Only ${groupLabel} leader can kick`); + // If the leader is being kicked, promote the kicker to leader + if (isMemberLeader && canEdit) { + await ctx.drizzle + .update(clan) + .set({ + leaderId: user.userId, // Promote the kicker to leader + }) + .where(eq(clan.id, fetchedClan.id)); + } // Mutate await removeFromClan(ctx.drizzle, fetchedClan, member, [ `Kicked by ${user.username}`, diff --git a/app/src/server/api/routers/profile.ts b/app/src/server/api/routers/profile.ts index ef89a21b..ce289d7f 100644 --- a/app/src/server/api/routers/profile.ts +++ b/app/src/server/api/routers/profile.ts @@ -413,6 +413,12 @@ export const profileRouter = createTRPCRouter({ where: and(eq(userData.userId, input.userId), eq(userData.isAi, true)), with: { jutsus: { with: { jutsu: true } }, items: { with: { item: true } } }, }); + // Filter off entries that do not exist + if (user) { + user.jutsus = user.jutsus.filter((j) => j.jutsu); + user.items = user.items.filter((i) => i.item); + } + // Return user return user ?? null; }), // Create new AI @@ -970,6 +976,9 @@ export const profileRouter = createTRPCRouter({ if (!requester || !canSeeIps(requester.role)) { user.lastIp = "hidden"; } + // Filter off entries that do not exist + user.jutsus = user.jutsus.filter((j) => j.jutsu); + user.items = user.items.filter((i) => i.item); // If no avatarLight version, create one if (!user.avatarLight && user.avatar) { const thumbnail = await createThumbnail(user.avatar); diff --git a/app/src/utils/permissions.ts b/app/src/utils/permissions.ts index 7afa621b..82a8a280 100644 --- a/app/src/utils/permissions.ts +++ b/app/src/utils/permissions.ts @@ -18,6 +18,7 @@ export const canPlayHiddenQuests = (role: UserRole) => { export const canSubmitNotification = (role: UserRole) => { return [ + "CODER", "CONTENT", "EVENT", "HEAD_MODERATOR", @@ -211,3 +212,7 @@ export const canChangeCombatBgScheme = (role: UserRole) => { export const canReviewLinkPromotions = (role: UserRole) => { return ["CODING-ADMIN"].includes(role); }; + +export const canEditClans = (role: UserRole) => { + return ["CONTENT-ADMIN", "CODING-ADMIN", "MODERATOR-ADMIN"].includes(role); +};