diff --git a/scripts/make-admin.ts b/scripts/make-admin.ts index 6bf37eb..59b1eb8 100644 --- a/scripts/make-admin.ts +++ b/scripts/make-admin.ts @@ -1,5 +1,5 @@ -// Grant or revoke the "Admin" role for a user by phone number (US +1 assumed). -// Usage: bun run scripts/make-admin.ts [--revoke] +// Grant or revoke a role for a user by phone number (US +1 assumed). +// Usage: bun run scripts/make-admin.ts [--role Admin|Developer] [--revoke] import "dotenv/config"; import SuperTokens from "supertokens-node"; import Session from "supertokens-node/recipe/session"; @@ -26,10 +26,19 @@ SuperTokens.init({ ], }); +const VALID_ROLES = ["Admin", "Developer"] as const; + const raw = process.argv[2]; const revoke = process.argv.includes("--revoke"); +const roleFlagIndex = process.argv.indexOf("--role"); +const role = roleFlagIndex === -1 ? "Admin" : process.argv[roleFlagIndex + 1]; + if (!raw) { - console.error("Usage: bun run scripts/make-admin.ts [--revoke]"); + console.error("Usage: bun run scripts/make-admin.ts [--role Admin|Developer] [--revoke]"); + process.exit(1); +} +if (!VALID_ROLES.includes(role as (typeof VALID_ROLES)[number])) { + console.error(`Invalid role "${role}". Valid roles: ${VALID_ROLES.join(", ")}`); process.exit(1); } @@ -60,15 +69,15 @@ if (!user) { process.exit(1); } -await UserRoles.createNewRoleOrAddPermissions("Admin", []); +await UserRoles.createNewRoleOrAddPermissions(role, []); if (revoke) { - const res = await UserRoles.removeUserRole("public", user.id, "Admin"); - console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status); + const res = await UserRoles.removeUserRole("public", user.id, role); + console.log(`Removed ${role} from ${matched} (user ${user.id}):`, res.status); } else { - const res = await UserRoles.addRoleToUser("public", user.id, "Admin"); + const res = await UserRoles.addRoleToUser("public", user.id, role); console.log( - `Granted Admin to ${matched} (user ${user.id}):`, + `Granted ${role} to ${matched} (user ${user.id}):`, res.status === "OK" ? res.didUserAlreadyHaveRole ? "already had it" diff --git a/src/app/routes/_authed/admin/telemetry.tsx b/src/app/routes/_authed/admin/telemetry.tsx index 5093635..0c5645a 100644 --- a/src/app/routes/_authed/admin/telemetry.tsx +++ b/src/app/routes/_authed/admin/telemetry.tsx @@ -1,7 +1,8 @@ -import { Outlet, createFileRoute, useNavigate, useRouterState } from "@tanstack/react-router"; +import { Outlet, createFileRoute, redirect, useNavigate, useRouterState } from "@tanstack/react-router"; import { Tabs } from "@mantine/core"; import { Trans } from "@lingui/react/macro"; import { msg } from "@lingui/core/macro"; +import { isDeveloperRole } from "@/features/core/utils/roles"; const TAB_ROUTES = { usage: "/admin/telemetry/usage", @@ -15,6 +16,11 @@ type TabValue = keyof typeof TAB_ROUTES; export const Route = createFileRoute("/_authed/admin/telemetry")({ component: TelemetryLayout, + beforeLoad: ({ context }) => { + if (!isDeveloperRole(context.auth?.roles)) { + throw redirect({ to: "/admin" }); + } + }, loader: () => ({ header: { title: msg`Telemetry`, diff --git a/src/app/routes/api/teams/upload-logo.ts b/src/app/routes/api/teams/upload-logo.ts index d2a8f74..61a10ca 100644 --- a/src/app/routes/api/teams/upload-logo.ts +++ b/src/app/routes/api/teams/upload-logo.ts @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { msg } from "@lingui/core/macro"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { isAdminRole } from "@/features/core/utils/roles"; import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; @@ -19,7 +20,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({ const i18n = localizedFor(context.metadata); try { const userId = context.userAuthId; - const isAdmin = context.roles.includes("Admin"); + const isAdmin = isAdminRole(context.roles); if (!userId) return new Response("Unauthenticated", { status: 401 }); diff --git a/src/app/routes/api/tournaments/upload-logo.ts b/src/app/routes/api/tournaments/upload-logo.ts index 7121c65..4f90362 100644 --- a/src/app/routes/api/tournaments/upload-logo.ts +++ b/src/app/routes/api/tournaments/upload-logo.ts @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { msg } from "@lingui/core/macro"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { isAdminRole } from "@/features/core/utils/roles"; import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; @@ -19,7 +20,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({ const i18n = localizedFor(context.metadata); try { const userId = context.userAuthId; - const isAdmin = context.roles.includes("Admin"); + const isAdmin = isAdminRole(context.roles); if (!userId) return new Response("Unauthenticated", { status: 401 }); if (!isAdmin) return new Response("Unauthorized", { status: 403 }); diff --git a/src/components/head-to-head-icon.tsx b/src/components/head-to-head-icon.tsx new file mode 100644 index 0000000..c2a58fe --- /dev/null +++ b/src/components/head-to-head-icon.tsx @@ -0,0 +1,35 @@ +import { Box } from "@mantine/core"; +import { FootballHelmetIcon } from "@phosphor-icons/react"; + +const HeadToHeadIcon = ({ size = 14 }: { size?: number }) => ( + + + + +); + +export default HeadToHeadIcon; diff --git a/src/features/activities/server.ts b/src/features/activities/server.ts index 2394ec2..77500ac 100644 --- a/src/features/activities/server.ts +++ b/src/features/activities/server.ts @@ -1,4 +1,4 @@ -import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens"; +import { superTokensDeveloperFunctionMiddleware } from "@/utils/supertokens"; import { createServerFn } from "@tanstack/react-start"; import { pbAdmin } from "@/lib/pocketbase/client"; import { z } from "zod"; @@ -17,7 +17,7 @@ const activitySearchParamsSchema = z.object({ export const searchActivities = createServerFn() .validator(activitySearchParamsSchema) - .middleware([superTokensAdminFunctionMiddleware]) + .middleware([superTokensDeveloperFunctionMiddleware]) .handler(async ({ data }) => toServerResult(async () => { const result = await pbAdmin.searchActivities(data); diff --git a/src/features/admin/components/dashboard/tools-list.tsx b/src/features/admin/components/dashboard/tools-list.tsx index 0126df1..7e70922 100644 --- a/src/features/admin/components/dashboard/tools-list.tsx +++ b/src/features/admin/components/dashboard/tools-list.tsx @@ -1,7 +1,6 @@ import { List, Text } from "@mantine/core"; import { Plural, Trans, useLingui } from "@lingui/react/macro"; import { - DatabaseIcon, TreeStructureIcon, TrophyIcon, MedalIcon, @@ -10,12 +9,14 @@ import { } from "@phosphor-icons/react"; import ListLink from "@/components/list-link"; import ListButton from "@/components/list-button"; +import { useIsDeveloper } from "@/features/core/hooks/use-is-developer"; import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server"; import { useServerQuery } from "@/lib/tanstack-query/hooks"; import toast from "@/lib/sonner"; export const ToolsList = () => { const { t } = useLingui(); + const isDeveloper = useIsDeveloper(); const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({ queryKey: ["admin", "badge-migration-status"], @@ -51,11 +52,13 @@ export const ToolsList = () => { Icon={TrophyIcon} to="/admin/tournaments" /> - + {isDeveloper && ( + + )} { Badge recalculation failed. Check the server logs. )} - - window.location.replace(process.env.POCKETBASE_URL! + "/_/") - } - /> { w={40} style={{ flexShrink: 0 }} > - - - - + )} diff --git a/src/features/core/hooks/use-is-developer.ts b/src/features/core/hooks/use-is-developer.ts new file mode 100644 index 0000000..a69c193 --- /dev/null +++ b/src/features/core/hooks/use-is-developer.ts @@ -0,0 +1,7 @@ +import { useAuth } from "@/contexts/auth-context"; +import { isDeveloperRole } from "../utils/roles"; + +export const useIsDeveloper = (): boolean => { + const { roles } = useAuth(); + return isDeveloperRole(roles); +}; diff --git a/src/features/core/utils/roles.ts b/src/features/core/utils/roles.ts index 84711a4..4e0da92 100644 --- a/src/features/core/utils/roles.ts +++ b/src/features/core/utils/roles.ts @@ -1,4 +1,8 @@ export const ADMIN_ROLE = "Admin"; +export const DEVELOPER_ROLE = "Developer"; + +export const isDeveloperRole = (roles?: string[] | null): boolean => + !!roles?.includes(DEVELOPER_ROLE); export const isAdminRole = (roles?: string[] | null): boolean => - !!roles?.includes(ADMIN_ROLE); + !!roles?.includes(ADMIN_ROLE) || isDeveloperRole(roles); diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx index ad258f1..fef6dcf 100644 --- a/src/features/matches/components/match-card.tsx +++ b/src/features/matches/components/match-card.tsx @@ -1,5 +1,5 @@ import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core"; -import { FootballHelmetIcon } from "@phosphor-icons/react"; +import HeadToHeadIcon from "@/components/head-to-head-icon"; import { useNavigate } from "@tanstack/react-router"; import { Match } from "../types"; import TeamAvatar from "@/components/team-avatar"; @@ -94,26 +94,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => { aria-label={t`View head-to-head`} w={40} > - - - - + )} diff --git a/src/features/players/components/profile/header.tsx b/src/features/players/components/profile/header.tsx index 92e02e2..6316e93 100644 --- a/src/features/players/components/profile/header.tsx +++ b/src/features/players/components/profile/header.tsx @@ -1,7 +1,8 @@ import Sheet from "@/components/sheet/sheet"; import { useAuth } from "@/contexts/auth-context"; -import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core"; -import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react"; +import { Flex, Title, ActionIcon, Stack, Button } from "@mantine/core"; +import { PencilIcon } from "@phosphor-icons/react"; +import HeadToHeadIcon from "@/components/head-to-head-icon"; import { useMemo } from "react"; import { useLingui } from "@lingui/react/macro"; import NameUpdateForm from "./name-form"; @@ -64,26 +65,7 @@ const Header = ({ player }: HeaderProps) => { w={40} display={!owner ? 'block' : 'none'} > - - - - + diff --git a/src/features/predictions/components/prediction-editor.tsx b/src/features/predictions/components/prediction-editor.tsx index 9012e70..02fcf9c 100644 --- a/src/features/predictions/components/prediction-editor.tsx +++ b/src/features/predictions/components/prediction-editor.tsx @@ -7,7 +7,8 @@ import { Stack, Text, } from "@mantine/core"; -import { CaretLeftIcon, CaretRightIcon, InfoIcon } from "@phosphor-icons/react"; +import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react"; +import HeadToHeadIcon from "@/components/head-to-head-icon"; import WizardOrbIcon from "@/components/wizard-orb-icon"; import { Trans, useLingui } from "@lingui/react/macro"; import { useNavigate } from "@tanstack/react-router"; @@ -228,7 +229,7 @@ export const PredictionEditor: React.FC = ({ disabled={!activeResolved?.home && !activeResolved?.away} aria-label={t`Matchup details`} > - + diff --git a/src/features/settings/components/notifications-section.tsx b/src/features/settings/components/notifications-section.tsx index 7153bf2..ae9399e 100644 --- a/src/features/settings/components/notifications-section.tsx +++ b/src/features/settings/components/notifications-section.tsx @@ -3,7 +3,7 @@ import { Box, Title, Stack, Switch, Button, Text, Group } from "@mantine/core"; import { Trans, useLingui } from "@lingui/react/macro"; import { useWebPush } from "@/hooks/use-web-push"; import { useAuth } from "@/contexts/auth-context"; -import { useIsAdmin } from "@/features/core/hooks/use-is-admin"; +import { useIsDeveloper } from "@/features/core/hooks/use-is-developer"; import { updateUserTelemetryAlerts } from "@/features/settings/server"; import toast from "@/lib/sonner"; @@ -40,7 +40,7 @@ const TelemetryAlertsSwitch = () => { export function NotificationsSection() { const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } = useWebPush(); - const isAdmin = useIsAdmin(); + const isDeveloper = useIsDeveloper(); const { t } = useLingui(); if (!supported || !configured) { @@ -99,7 +99,7 @@ export function NotificationsSection() { disabled={busy} label={t`Enable notifications on this device`} /> - {subscribed && isAdmin && } + {subscribed && isDeveloper && } {subscribed && (