Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions src/app/(public)/account-settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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),
});
Expand Down
51 changes: 37 additions & 14 deletions src/app/admin/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<Sidebar className="text-foreground ">
<SidebarHeader className="px-6 py-4 decoration-3 items-center bg-[#fa7909]">
<h2 className="text-2xl mt-2 transition-colors">
{t("admin:title")}
</h2>
</SidebarHeader>
<SidebarContent className="px-2 gap-2 bg-[#fa7909]">
<div className="px-3 py-2 space-y-3">
<Skeleton className="h-4 w-full bg-white/30" />
<Skeleton className="h-9 w-full bg-white/25" />
<Skeleton className="h-9 w-full bg-white/25" />
<Skeleton className="h-9 w-full bg-white/25" />
</div>
</SidebarContent>
</Sidebar>
);
}

// 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 (
<Sidebar className="text-foreground ">
Expand Down
5 changes: 2 additions & 3 deletions src/app/admin/members/MemberEditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<UserUpdate>({
Expand Down
4 changes: 2 additions & 2 deletions src/app/admin/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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[]);
Expand Down
6 changes: 4 additions & 2 deletions src/components/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/components/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type DefaultError,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
getMeOptions,
Expand Down Expand Up @@ -71,6 +72,7 @@ export function NavBar() {
...getMeOptions(),
refetchOnWindowFocus: false,
});
const queryClient = useQueryClient();
const loginHandler = useLoginHandler();
const logoutMutation = useMutation({
...authCookieLogoutMutation({ credentials: "include" }),
Expand All @@ -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) => {
Expand Down
30 changes: 22 additions & 8 deletions src/components/PermissionWall.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-center min-h-screen bg-background text-foreground">
<section className="text-center">
Expand All @@ -19,15 +21,15 @@ function PermissionDenied() {
<p className="mb-4 text-3xl font-bold md:text-4xl">
{t("permission-wall.subtitle")}
</p>
<p className="mb-4 text-lg font-light text-muted-foreground">
<div className="mb-4 text-lg font-light text-muted-foreground">
Comment thread
georgelgeback marked this conversation as resolved.
{t("permission-wall.message")}
<Obfuscate email={"spindelman@fsektionen.se"}>
<p className="inline-flex text-forange hover:bg-primary hover:text-white">
<span className="inline-flex text-forange hover:bg-primary hover:text-white">
{t("permission-wall.contact")}
</p>
</span>
</Obfuscate>
Comment thread
georgelgeback marked this conversation as resolved.
.
</p>
</div>
<p className="mb-4 text-lg font-light text-muted-foreground">
<i>{t("permission-wall.quote")}</i> -{" "}
{t("permission-wall.quote_author")}
Expand All @@ -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 <LoadingErrorCard />;
}

if (permissionsState.isError) {
return (
<LoadingErrorCard error={permissionsState.error} isLoading={false} />
);
}

const perm = permissionsState.permissions;

let allowed = false;
if (mustHave === "all") {
allowed = perm.hasRequiredPermissions(requiredPermissions);
Expand All @@ -66,5 +80,5 @@ export default function PermissionWall({
if (allowed) {
return <>{children}</>;
}
return PermissionDenied();
return <PermissionDenied />;
}
91 changes: 46 additions & 45 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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<TargetEnum, Set<ActionEnum>> {

export class PermissionMap extends Map<TargetEnum, Set<ActionEnum>> {
/**
* Checks a users permission against a list of required permissions.
*
Expand All @@ -15,9 +19,8 @@ class PermissionMap extends Map<TargetEnum, Set<ActionEnum>> {
* 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]]);
* ```
*/
Expand All @@ -37,58 +40,59 @@ type AuthState = {
setAccessToken: (data: BearerResponse) => void;
authorizationHeader: () => string | null;
isAuthenticated: () => boolean;
getPermissions: () => PermissionMap;
};

export const useAuthState = create<AuthState>((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;
}

Comment thread
georgelgeback marked this conversation as resolved.
const [actionStr, targetStr] = parts;
if (!map.has(targetEnum)) {
map.set(targetEnum, new Set<ActionEnum>());
}
// 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<ActionEnum>());
}
// 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<typeof usePermissionsState>;

export const useAuthState = create<AuthState>((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;
Expand All @@ -114,8 +118,5 @@ export const useAuthState = create<AuthState>((set, get) => {
isAuthenticated() {
return !!get().authorizationHeader();
},
getPermissions() {
return permissionMap;
},
};
});
2 changes: 1 addition & 1 deletion src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading