arbret/frontend/app/auth-context.tsx
counterweight 21698203fe
refactor(auth): unify authorization patterns with MANAGE_OWN_PROFILE permission
Issue #2: The profile route used a custom role-based check instead
of the permission-based pattern used everywhere else.

Changes:
- Add MANAGE_OWN_PROFILE permission to backend Permission enum
- Add permission to ROLE_REGULAR role definition
- Update profile routes to use require_permission(MANAGE_OWN_PROFILE)
- Remove custom require_regular_user dependency
- Update frontend Permission constant and profile page
- Update invites page to use permission instead of role check
- Update profile tests with proper permission mocking

This ensures consistent authorization patterns across all routes.
2025-12-21 23:50:06 +01:00

141 lines
3.8 KiB
TypeScript

"use client";
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import { api, ApiError } from "./api";
import { components } from "./generated/api";
// Permission constants - must match backend/models.py Permission enum.
// Backend exposes these via GET /api/meta/constants for validation.
// TODO: Generate this from the backend endpoint at build time.
export const Permission = {
VIEW_COUNTER: "view_counter",
INCREMENT_COUNTER: "increment_counter",
USE_SUM: "use_sum",
VIEW_AUDIT: "view_audit",
MANAGE_OWN_PROFILE: "manage_own_profile",
MANAGE_INVITES: "manage_invites",
VIEW_OWN_INVITES: "view_own_invites",
// Booking permissions (regular users)
BOOK_APPOINTMENT: "book_appointment",
VIEW_OWN_APPOINTMENTS: "view_own_appointments",
CANCEL_OWN_APPOINTMENT: "cancel_own_appointment",
// Availability/Appointments permissions (admin)
MANAGE_AVAILABILITY: "manage_availability",
VIEW_ALL_APPOINTMENTS: "view_all_appointments",
CANCEL_ANY_APPOINTMENT: "cancel_any_appointment",
} as const;
export type PermissionType = (typeof Permission)[keyof typeof Permission];
// Use generated type from OpenAPI schema
type User = components["schemas"]["UserResponse"];
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, inviteIdentifier: string) => Promise<void>;
logout: () => Promise<void>;
hasPermission: (permission: PermissionType) => boolean;
hasRole: (role: string) => boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
try {
const userData = await api.get<User>("/api/auth/me");
setUser(userData);
} catch {
// Not authenticated
} finally {
setIsLoading(false);
}
};
const login = async (email: string, password: string) => {
try {
const userData = await api.post<User>("/api/auth/login", { email, password });
setUser(userData);
} catch (err) {
if (err instanceof ApiError) {
const data = err.data as { detail?: string };
throw new Error(data?.detail || "Login failed");
}
throw err;
}
};
const register = async (email: string, password: string, inviteIdentifier: string) => {
try {
const userData = await api.post<User>("/api/auth/register", {
email,
password,
invite_identifier: inviteIdentifier,
});
setUser(userData);
} catch (err) {
if (err instanceof ApiError) {
const data = err.data as { detail?: string };
throw new Error(data?.detail || "Registration failed");
}
throw err;
}
};
const logout = async () => {
try {
await api.post("/api/auth/logout");
} catch {
// Ignore errors on logout
}
setUser(null);
};
const hasPermission = useCallback(
(permission: PermissionType): boolean => {
return user?.permissions.includes(permission) ?? false;
},
[user]
);
const hasRole = useCallback(
(role: string): boolean => {
return user?.roles.includes(role) ?? false;
},
[user]
);
return (
<AuthContext.Provider
value={{
user,
isLoading,
login,
register,
logout,
hasPermission,
hasRole,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}