diff --git a/src/app/(public)/account-settings/page.tsx b/src/app/(public)/account-settings/page.tsx index 3169b06f..b863b646 100644 --- a/src/app/(public)/account-settings/page.tsx +++ b/src/app/(public)/account-settings/page.tsx @@ -40,6 +40,7 @@ import { } from "@/components/ui/form"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import ChangeEmailPassForm from "@/components/ChangeEmailPassForm"; +import getErrorMessage from "@/help_functions/getErrorMessage"; export default function AccountSettingsPage() { const { t } = useTranslation("user-settings"); @@ -116,13 +117,7 @@ export default function AccountSettingsPage() { toast.success(t("user-settings:update-success")); }, onError: (error) => { - toast.error( - typeof error === "string" - ? error - : error instanceof Error - ? error.message - : t("user-settings:update-error"), - ); + toast.error(getErrorMessage(error, t)); }, onSettled: () => setIsSaving(false), }); diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index ba247631..20dc17c0 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -44,8 +44,9 @@ import { import { useTranslation } from "react-i18next"; import Link from "next/link"; import type { ForwardRefExoticComponent, RefAttributes } from "react"; -import { useAuthState, type RequiredPermission } from "@/lib/auth"; +import { usePermissionsState, type RequiredPermission } from "@/lib/auth"; import { ActionEnum, TargetEnum } from "@/api"; +import { Skeleton } from "@/components/ui/skeleton"; type AdminSidebarEntry = { title: string; @@ -229,20 +230,42 @@ const groups: AdminGroup[] = [ export function AdminSidebar() { const { t } = useTranslation(); - const permissions = useAuthState().getPermissions(); + const { permissions, isLoading, isError } = usePermissionsState(); - // Filter out groups and entries the user doesn't have permission to see - const visibleGroups = groups - .map((group) => { - return { - ...group, - // Also filter out entries - entries: group.entries.filter((item) => - permissions.hasRequiredPermissions(item.permissions ?? []), - ), - }; - }) - .filter((group) => group.entries.length > 0); + if (isLoading) { + return ( + + +

+ {t("admin:title")} +

+
+ +
+ + + + +
+
+
+ ); + } + + // Filter out groups and entries the user doesn't have permission to see. + // On permission fetch errors we fail closed and show no admin entries. + const visibleGroups = isError + ? [] + : groups + .map((group) => { + return { + ...group, + entries: group.entries.filter((item) => + permissions.hasRequiredPermissions(item.permissions ?? []), + ), + }; + }) + .filter((group) => group.entries.length > 0); return ( diff --git a/src/app/admin/members/MemberEditForm.tsx b/src/app/admin/members/MemberEditForm.tsx index 446730f3..039cdc0e 100644 --- a/src/app/admin/members/MemberEditForm.tsx +++ b/src/app/admin/members/MemberEditForm.tsx @@ -21,7 +21,7 @@ import { toast } from "sonner"; import { AdminChooseMultPosts } from "@/widgets/AdminChooseMultPosts"; import { Pen, Save } from "lucide-react"; import UserDetailsCard from "@/components/UserDetailsCard"; -import { useAuthState } from "@/lib/auth"; +import { usePermissions } from "@/lib/auth"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox"; @@ -73,8 +73,7 @@ export default function UserPostsEditForm({ const { t } = useTranslation("admin"); const [confirmOpen, setConfirmOpen] = useState(false); const [isEditing, setIsEditing] = useState(false); - const auth = useAuthState(); - const permissions = auth.getPermissions(); + const permissions = usePermissions(); const queryClient = useQueryClient(); const form = useForm({ diff --git a/src/app/admin/members/page.tsx b/src/app/admin/members/page.tsx index 0a780e99..4ecc4c33 100644 --- a/src/app/admin/members/page.tsx +++ b/src/app/admin/members/page.tsx @@ -37,7 +37,7 @@ import { useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { LoadingErrorCard } from "@/components/LoadingErrorCard"; -import { useAuthState, type RequiredPermission } from "@/lib/auth"; +import { usePermissions, type RequiredPermission } from "@/lib/auth"; import { ActionEnum, TargetEnum } from "@/api"; import MemberEditForm from "./MemberEditForm"; @@ -53,7 +53,7 @@ export default function MembersPage() { } = useQuery({ ...adminGetAllUsersOptions(), }); - const permissions = useAuthState().getPermissions(); + const permissions = usePermissions(); const hasManageUserPerms = permissions.hasRequiredPermissions([ [ActionEnum.MANAGE, TargetEnum.USER], ] as RequiredPermission[]); diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 2209f075..d7e20375 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -15,7 +15,7 @@ import { import { Input } from "@/components/ui/input"; import { useAuthState } from "@/lib/auth"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -32,6 +32,7 @@ const emailPasswordSchema = z.object({ export default function LoginForm() { const { t } = useTranslation(); const router = useRouter(); + const queryClient = useQueryClient(); const searchParams = useSearchParams(); const [submitEnabled, setSubmitEnabled] = useState(true); const auth = useAuthState(); @@ -66,9 +67,10 @@ export default function LoginForm() { }, onSuccess: (data) => { auth.setAccessToken(data); + queryClient.clear(); // After logging in, we want fresh data const next = searchParams.get("next") || "/home"; router.push(next); - // Set a cookie to indicate the user is not authenticated, just for the middleware to check if it should redirect + // Set a cookie to indicate the user is now authenticated, just for the middleware to check if it should redirect // obviously this is not secure enough for real authentication const expires = new Date(); expires.setFullYear(expires.getFullYear() + 1); diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index ea210e04..b9a0d1d3 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -28,6 +28,7 @@ import { type DefaultError, useMutation, useQuery, + useQueryClient, } from "@tanstack/react-query"; import { getMeOptions, @@ -71,6 +72,7 @@ export function NavBar() { ...getMeOptions(), refetchOnWindowFocus: false, }); + const queryClient = useQueryClient(); const loginHandler = useLoginHandler(); const logoutMutation = useMutation({ ...authCookieLogoutMutation({ credentials: "include" }), @@ -79,6 +81,7 @@ export function NavBar() { // obviously this is not secure enough for real authentication document.cookie = "auth_status=unauthenticated; path=/; SameSite=Strict; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + queryClient.clear(); router.push("/"); }, onError: (error: DefaultError) => { diff --git a/src/components/PermissionWall.tsx b/src/components/PermissionWall.tsx index 68c1a053..0d182252 100644 --- a/src/components/PermissionWall.tsx +++ b/src/components/PermissionWall.tsx @@ -1,15 +1,17 @@ "use client"; -import { useAuthState, type RequiredPermission } from "@/lib/auth"; +import { usePermissionsState, type RequiredPermission } from "@/lib/auth"; import { useRouter } from "next/navigation"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import { LoadingErrorCard } from "@/components/LoadingErrorCard"; import Obfuscate from "react-obfuscate"; function PermissionDenied() { const { t } = useTranslation("main"); const router = useRouter(); + return (
@@ -19,15 +21,15 @@ function PermissionDenied() {

{t("permission-wall.subtitle")}

-

+

{t("permission-wall.message")} -

+ {t("permission-wall.contact")} -

+
. -

+

{t("permission-wall.quote")} -{" "} {t("permission-wall.quote_author")} @@ -53,8 +55,20 @@ export default function PermissionWall({ mustHave?: "any" | "all"; children: ReactNode; }) { - const auth = useAuthState(); - const perm = auth.getPermissions(); + const permissionsState = usePermissionsState(); + + if (permissionsState.isPending) { + return ; + } + + if (permissionsState.isError) { + return ( + + ); + } + + const perm = permissionsState.permissions; + let allowed = false; if (mustHave === "all") { allowed = perm.hasRequiredPermissions(requiredPermissions); @@ -66,5 +80,5 @@ export default function PermissionWall({ if (allowed) { return <>{children}; } - return PermissionDenied(); + return ; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d8ac25a1..d90357a7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,11 +1,15 @@ "use client"; +import { getMyPermissionsOptions } from "@/api/@tanstack/react-query.gen"; import type { BearerResponse } from "@/api"; import type { ActionEnum, TargetEnum } from "@/api"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; import { create } from "zustand"; export type RequiredPermission = [ActionEnum, TargetEnum]; -class PermissionMap extends Map> { + +export class PermissionMap extends Map> { /** * Checks a users permission against a list of required permissions. * @@ -15,9 +19,8 @@ class PermissionMap extends Map> { * Checking that a user has `view` permission for `CAR` and `manage` permission for `USER` * ```ts * import type { ActionEnum, TargetEnum } from "@/api"; - * import { useAuthState } from "@/lib/auth"; - * const auth = useAuthState(); - * const permissions = auth.getPermissions(); + * import { usePermissions } from "@/lib/auth"; + * const permissions = usePermissions(); * const isAllowed = permissions.hasRequiredPermissions([[ActionEnum.VIEW, TargetEnum.CAR], [ActionEnum.MANAGE, TargetEnum.USER]]); * ``` */ @@ -37,58 +40,59 @@ type AuthState = { setAccessToken: (data: BearerResponse) => void; authorizationHeader: () => string | null; isAuthenticated: () => boolean; - getPermissions: () => PermissionMap; }; -export const useAuthState = create((set, get) => { - let permissionMap = new PermissionMap(); +function buildPermissionMap( + permissions: [string, string][] | null | undefined, +): PermissionMap { + const map = new PermissionMap(); + if (!permissions) return map; - function buildPermissionMap(token: BearerResponse | null): PermissionMap { - const map = new PermissionMap(); - if (!token) return map; - try { - const payload = JSON.parse( - Buffer.from(token.access_token.split(".")[1], "base64").toString(), - ) as { permissions: string[] }; + for (const [actionStr, targetStr] of permissions) { + const actionEnum = actionStr as ActionEnum; + const targetEnum = targetStr as TargetEnum; - for (const entry of payload.permissions) { - const parts = entry.split(":"); - if (parts.length !== 2) continue; + if (!actionEnum || !targetEnum) { + console.warn( + `Unknown permission from backend: ${actionStr}:${targetStr}`, + ); + continue; + } - const [actionStr, targetStr] = parts; + if (!map.has(targetEnum)) { + map.set(targetEnum, new Set()); + } + // biome-ignore lint/style/noNonNullAssertion: Just checked that it exists + map.get(targetEnum)!.add(actionEnum); + } - const actionEnum = actionStr as ActionEnum; - const targetEnum = targetStr as TargetEnum; + return map; +} - if (!actionEnum || !targetEnum) continue; +export function usePermissions(): PermissionMap { + return usePermissionsState().permissions; +} - if (!map.has(targetEnum)) { - map.set(targetEnum, new Set()); - } - // biome-ignore lint/style/noNonNullAssertion: Just checked that it exists - map.get(targetEnum)!.add(actionEnum); - } - } catch { - // If decoding or parsing fails, just return an empty map - } +export function usePermissionsState() { + const query = useQuery({ + ...getMyPermissionsOptions(), + staleTime: 60 * 1000, + }); + const permissions = useMemo( + () => buildPermissionMap(query.data), + [query.data], + ); - return map; - } + return { ...query, permissions }; +} - function updatePermissions(token: BearerResponse | null) { - permissionMap = buildPermissionMap(token); - } +export type UsePermissionsState = ReturnType; +export const useAuthState = create((set, get) => { return { accessToken: null, setAccessToken(data) { - set((state) => { - if (state.accessToken?.access_token === data.access_token) { - return { accessToken: data }; - } - updatePermissions(data); - return { accessToken: data }; - }); + set({ accessToken: data }); }, authorizationHeader() { const accessToken = get().accessToken; @@ -114,8 +118,5 @@ export const useAuthState = create((set, get) => { isAuthenticated() { return !!get().authorizationHeader(); }, - getPermissions() { - return permissionMap; - }, }; }); diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 88e9a5b4..c2f34e68 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -366,7 +366,7 @@ "stop": "Stop", "subtitle": "Slow down there!", "message": "You do not have permission to view this page. If you believe this is an error, ", - "contact": "please contact your local webmaster.", + "contact": "please contact your local webmaster", "quote": "You are not a subset of the set of all users with access to this page.", "quote_author": "The wise Debianne server", "button": "Back to homepage" diff --git a/src/locales/sv/main.json b/src/locales/sv/main.json index ad0443d9..6e548342 100644 --- a/src/locales/sv/main.json +++ b/src/locales/sv/main.json @@ -366,7 +366,7 @@ "stop": "Stopp", "subtitle": "Sakta i backarna!", "message": "Du har inte behörighet att se denna sida. Om du tror att något är fel, ", - "contact": "kontakta din lokala spindelman.", + "contact": "kontakta din lokala spindelman", "quote": "Här har vi begått lite småillegala saker längs vägen.", "quote_author": "Mikael \"Micke P\" Persson Sundqvist", "button": "Tillbaka till hemsidan"