Restructure frontend #8

Merged
Sluijsens merged 1 commits from feature/frontend-feature-based-structure into master 2026-07-31 23:51:55 +02:00
83 changed files with 249 additions and 254 deletions
-127
View File
@@ -1,127 +0,0 @@
// Domain types shared across the frontend. Names align with backend payloads
// (the user property is `name`, never `naam`) — see BR-U1-10.
// Roles aligned with backend authorization.
export type UserRole = 'Owner' | 'Administrator' | 'User';
export interface User {
id: string; // UUID
email: string;
name: string;
role: UserRole;
isActive: boolean;
}
export interface AuthResponse {
accessToken: string; // JWT access token
expiresAt: string; // ISO timestamp
user: User;
}
export interface LoginRequest {
email: string;
password: string;
}
// RFC 9457 ProblemDetails for standardized error handling.
export interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
// Extension members (e.g. traceId, errors).
[extension: string]: unknown;
}
// Convenience wrapper for API results (optional usage).
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: ProblemDetails };
// Setup status (used by guards during bootstrap).
export interface SetupStatus {
initialized: boolean;
}
export interface SetupRequest {
name: string;
email: string;
password: string;
}
export interface InvitationValidation {
email: string;
name: string | null;
isValid: boolean;
errorCode: 'EXPIRED' | 'USED' | 'NOT_FOUND' | null;
}
export interface InviteCompleteRequest {
token: string;
name: string;
password: string;
}
export interface UserListItem extends User {
createdAt: string;
invitationPending: boolean;
inviteLink: string | null;
}
export interface InviteUserPayload {
email: string;
role: Exclude<UserRole, 'Owner'>;
}
export interface InviteUserResponse {
inviteLink: string;
}
export interface ChangeRolePayload {
newRole: UserRole;
}
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'NotAvailable';
export type CmsInstanceStatus = 'Available' | 'NotAvailable' | 'Inactive';
export interface CmsInstance {
id: string;
name: string;
url: string;
status: CmsInstanceStatus;
disableMessage: string | null;
lastContactedAt: string | null;
lastStatusPushedAt: string | null;
lastIntegrityCheckFailedAt: string | null;
}
export interface CreateCmsInstanceRequest {
name: string;
url: string;
apiKey: string;
}
export interface UpdateCmsInstanceStatusRequest {
status: CmsInstanceStatus;
disableMessage: string | null;
}
export interface UpdateStatusResult {
success: boolean;
slaveContactSuccess: boolean;
}
export interface AvailabilityResponse {
status: AvailabilityStatus;
checkedAt: string; // ISO 8601
message: string;
/** True when a Master CMS has taken control of this instance's gate — the local status cannot be changed here. */
isMasterControlled: boolean;
}
// Which optional modules (e.g. "Master") this backend instance has loaded —
// lets the frontend tell a master-only feature apart from a slave instance
// without that module (local master/slave dev setup).
export interface SystemCapabilities {
modules: string[];
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { useAuth } from '@/features/auth/context/auth-context';
import { useAvailabilityStatus } from '@/features/availability/services/useAvailability';
import { Sidebar } from '@/components/layout/Sidebar';
import { MobileBar } from '@/components/layout/MobileBar';
import { SidebarOverlay } from '@/components/layout/SidebarOverlay';
@@ -5,7 +5,7 @@ import { server } from '@/mocks/server';
import { setupHandlers } from '@/mocks/index';
import { _resetSetupStatusCache } from '@/router';
import { http, HttpResponse } from 'msw';
import { API_BASE, makeAuthResponse, mockUser } from '@/mocks/auth/fixtures';
import { API_BASE, makeAuthResponse, mockUser } from '@/features/auth/mocks/fixtures';
beforeEach(() => {
_resetSetupStatusCache();
+3 -3
View File
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Users, FileText, Settings, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { useSystemCapabilities } from '@/api/useSystemCapabilities';
import { useAuth } from '@/features/auth/context/auth-context';
import { useAvailabilityStatus } from '@/features/availability/services/useAvailability';
import { useSystemCapabilities } from '@/features/system/services/useSystemCapabilities';
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import { ThemeToggle } from './ThemeToggle';
import { UserMenu } from './UserMenu';
+1 -1
View File
@@ -11,7 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuth } from '@/contexts/auth-context';
import { useAuth } from '@/features/auth/context/auth-context';
export function UserMenu() {
const { t } = useTranslation();
+1 -1
View File
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import type { UserRole } from '@/api/types';
import type { UserRole } from '@/features/auth/services/types';
function roleBadgeVariant(role: UserRole): 'default' | 'secondary' | 'outline' {
if (role === 'Owner') return 'default';
@@ -3,7 +3,7 @@ import { screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { renderApp } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE, makeAuthResponse, mockUser } from '@/mocks/auth/fixtures';
import { API_BASE, makeAuthResponse, mockUser } from '@/features/auth/mocks/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import type { UserRole } from '@/api/types';
import { useAuth } from '@/contexts/auth-context';
import type { UserRole } from '@/features/auth/services/types';
import { useAuth } from '@/features/auth/context/auth-context';
interface RoleGuardProps {
allowedRoles: UserRole[];
@@ -3,11 +3,11 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { I18nextProvider } from 'react-i18next';
import type { ReactNode } from 'react';
import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { AuthProvider } from '@/features/auth/context/AuthProvider';
import { useAuth } from '@/features/auth/context/auth-context';
import { api } from '@/lib/api-client';
import { server } from '@/mocks/server';
import { API_BASE, mockUser } from '@/mocks/auth/fixtures';
import { API_BASE, mockUser } from '@/features/auth/mocks/fixtures';
import { mockGuest } from '@/test/utils';
import i18n from '@/i18n/config';
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { AuthResponse } from '@/api/types';
import type { AuthResponse } from '@/features/auth/services/types';
import { api } from '@/lib/api-client';
import { AuthContext, type AuthContextValue, type AuthStatus } from '@/contexts/auth-context';
import { AuthContext, type AuthContextValue, type AuthStatus } from '@/features/auth/context/auth-context';
/**
* Owns the in-memory authentication session. On mount it attempts a silent
@@ -1,5 +1,5 @@
import { createContext, useContext } from 'react';
import type { User } from '@/api/types';
import type { User } from '@/features/auth/services/types';
export type AuthStatus = 'loading' | 'authenticated' | 'guest';
@@ -1,4 +1,4 @@
import type { AuthResponse, User } from '@/api/types';
import type { AuthResponse, User } from '@/features/auth/services/types';
import { getAppConfig } from '@/lib/config';
/**
@@ -1,5 +1,6 @@
import { http, HttpResponse } from 'msw';
import type { LoginRequest, ProblemDetails } from '@/api/types';
import type { LoginRequest } from '@/features/auth/services/types';
import type { ProblemDetails } from '@/lib/api-client';
import { API_BASE, makeAuthResponse, TEST_CREDENTIALS } from './fixtures';
function problem(status: number, title: string, detail?: string): ProblemDetails {
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockGuest } from '@/test/utils';
import { TEST_CREDENTIALS } from '@/mocks/auth/fixtures';
import { TEST_CREDENTIALS } from '@/features/auth/mocks/fixtures';
describe('LoginPage', () => {
it('shows validation errors when submitting an empty form', async () => {
@@ -7,9 +7,9 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/contexts/auth-context';
import { useAuth } from '@/features/auth/context/auth-context';
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
import { loginFormSchema, type LoginFormData } from '@/lib/schemas/auth';
import { loginFormSchema, type LoginFormData } from '@/features/auth/schemas/auth';
export function LoginPage() {
const { t } = useTranslation();
@@ -4,7 +4,7 @@ import {
setupFormSchema,
loginFormSchema,
invitationCompleteFormSchema,
} from '@/lib/schemas/auth';
} from '@/features/auth/schemas/auth';
describe('passwordSchema', () => {
it('accepts a valid password', () => {
@@ -0,0 +1,21 @@
// Roles aligned with backend authorization.
export type UserRole = 'Owner' | 'Administrator' | 'User';
export interface User {
id: string; // UUID
email: string;
name: string;
role: UserRole;
isActive: boolean;
}
export interface AuthResponse {
accessToken: string; // JWT access token
expiresAt: string; // ISO timestamp
user: User;
}
export interface LoginRequest {
email: string;
password: string;
}
@@ -1,6 +1,6 @@
import { AlertTriangle, CheckCircle, Clock, XCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { AvailabilityStatus } from '@/api/types';
import type { AvailabilityStatus } from '@/features/availability/services/types';
interface AvailabilityStatusBadgeProps {
status: AvailabilityStatus;
@@ -0,0 +1,9 @@
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'NotAvailable';
export interface AvailabilityResponse {
status: AvailabilityStatus;
checkedAt: string; // ISO 8601
message: string;
/** True when a Master CMS has taken control of this instance's gate — the local status cannot be changed here. */
isMasterControlled: boolean;
}
@@ -4,7 +4,7 @@ import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useAvailabilityStatus } from './useAvailability';
function createWrapper() {
@@ -4,8 +4,8 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { resetMockCmsInstances } from '@/mocks/cms/handlers';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { resetMockCmsInstances } from '@/features/cms/mocks/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
@@ -15,9 +15,9 @@ import {
} from '@/components/ui/dialog';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useAddCmsInstance } from '@/api/useAddCmsInstance';
import { useAddCmsInstance } from '@/features/cms/services/useAddCmsInstance';
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
import { addCmsInstanceSchema, type AddCmsInstanceFormData } from '@/lib/schemas/cms';
import { addCmsInstanceSchema, type AddCmsInstanceFormData } from '@/features/cms/schemas/cms';
interface AddCmsInstanceDialogProps {
open: boolean;
@@ -15,7 +15,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import type { CmsInstance, CmsInstanceStatus } from '@/api/types';
import type { CmsInstance, CmsInstanceStatus } from '@/features/cms/services/types';
const STATUS_BADGE_CONFIG: Record<
CmsInstanceStatus,
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { resetMockCmsInstances } from '@/mocks/cms/handlers';
import { resetMockCmsInstances } from '@/features/cms/mocks/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
@@ -21,10 +21,10 @@ import {
} from '@/components/ui/select';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useUpdateCmsInstanceStatus } from '@/api/useUpdateCmsInstanceStatus';
import { useUpdateCmsInstanceStatus } from '@/features/cms/services/useUpdateCmsInstanceStatus';
import { NetworkError } from '@/lib/api-client';
import { setStatusSchema, type SetStatusFormData } from '@/lib/schemas/cms';
import type { CmsInstance, CmsInstanceStatus } from '@/api/types';
import { setStatusSchema, type SetStatusFormData } from '@/features/cms/schemas/cms';
import type { CmsInstance, CmsInstanceStatus } from '@/features/cms/services/types';
interface SetStatusDialogProps {
instance: CmsInstance | null;
@@ -1,6 +1,6 @@
import { http, HttpResponse } from 'msw';
import type { CmsInstance, UpdateStatusResult } from '@/api/types';
import { API_BASE } from '../auth/fixtures';
import type { CmsInstance, UpdateStatusResult } from '@/features/cms/services/types';
import { API_BASE } from '@/features/auth/mocks/fixtures';
const seed: CmsInstance[] = [
{
@@ -4,8 +4,8 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { resetMockCmsInstances } from '@/mocks/cms/handlers';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { resetMockCmsInstances } from '@/features/cms/mocks/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
@@ -2,11 +2,11 @@ import { useState } from 'react';
import { LayoutGrid } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { CmsInstanceList } from '@/components/cms/CmsInstanceList';
import { AddCmsInstanceDialog } from '@/components/cms/AddCmsInstanceDialog';
import { SetStatusDialog } from '@/components/cms/SetStatusDialog';
import { useCmsInstances } from '@/api/useCmsInstances';
import type { CmsInstance } from '@/api/types';
import { CmsInstanceList } from '@/features/cms/components/CmsInstanceList';
import { AddCmsInstanceDialog } from '@/features/cms/components/AddCmsInstanceDialog';
import { SetStatusDialog } from '@/features/cms/components/SetStatusDialog';
import { useCmsInstances } from '@/features/cms/services/useCmsInstances';
import type { CmsInstance } from '@/features/cms/services/types';
export function CmsPage() {
const { t } = useTranslation();
@@ -0,0 +1,28 @@
export type CmsInstanceStatus = 'Available' | 'NotAvailable' | 'Inactive';
export interface CmsInstance {
id: string;
name: string;
url: string;
status: CmsInstanceStatus;
disableMessage: string | null;
lastContactedAt: string | null;
lastStatusPushedAt: string | null;
lastIntegrityCheckFailedAt: string | null;
}
export interface CreateCmsInstanceRequest {
name: string;
url: string;
apiKey: string;
}
export interface UpdateCmsInstanceStatusRequest {
status: CmsInstanceStatus;
disableMessage: string | null;
}
export interface UpdateStatusResult {
success: boolean;
slaveContactSuccess: boolean;
}
@@ -4,7 +4,7 @@ import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useAddCmsInstance } from './useAddCmsInstance';
function createWrapper() {
@@ -4,8 +4,8 @@ import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { getMockCmsInstances } from '@/mocks/cms/handlers';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { getMockCmsInstances } from '@/features/cms/mocks/handlers';
import { useCmsInstances } from './useCmsInstances';
function createWrapper() {
@@ -4,7 +4,7 @@ import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useUpdateCmsInstanceStatus } from './useUpdateCmsInstanceStatus';
function createWrapper() {
@@ -1,9 +1,9 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { AvailabilityStatusBadge } from '@/components/shared/AvailabilityStatusBadge';
import { useAuth } from '@/features/auth/context/auth-context';
import { useAvailabilityStatus } from '@/features/availability/services/useAvailability';
import { AvailabilityStatusBadge } from '@/features/availability/components/AvailabilityStatusBadge';
export function DashboardPage() {
const { t } = useTranslation();
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
@@ -21,8 +21,8 @@ import {
} from '@/components/ui/select';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useInviteUser } from '@/api/useUsers';
import { inviteUserSchema, type InviteUserFormData } from '@/lib/schemas/users';
import { useInviteUser } from '@/features/users/services/useUsers';
import { inviteUserSchema, type InviteUserFormData } from '@/features/users/schemas/users';
import { NetworkError } from '@/lib/api-client';
interface InviteUserDialogProps {
@@ -1,5 +1,5 @@
import { http, HttpResponse } from 'msw';
import { API_BASE } from '../auth/fixtures';
import { API_BASE } from '@/features/auth/mocks/fixtures';
export const invitationHandlers = [
http.get(`${API_BASE}/api/v1/Invitation/validate`, ({ request }) => {
@@ -7,10 +7,10 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { invitationCompleteFormSchema, type InvitationCompleteFormData } from '@/lib/schemas/auth';
import { invitationCompleteFormSchema, type InvitationCompleteFormData } from '@/features/auth/schemas/auth';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useValidateInvitation, useCompleteInvitation } from '@/api/useInvitation';
import { useValidateInvitation, useCompleteInvitation } from '@/features/invitation/services/useInvitation';
import { ProblemDetailsError, NetworkError } from '@/lib/api-client';
interface FormError {
@@ -0,0 +1,12 @@
export interface InvitationValidation {
email: string;
name: string | null;
isValid: boolean;
errorCode: 'EXPIRED' | 'USED' | 'NOT_FOUND' | null;
}
export interface InviteCompleteRequest {
token: string;
name: string;
password: string;
}
@@ -4,8 +4,8 @@ import { createElement } from 'react';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { useValidateInvitation, useCompleteInvitation } from '@/api/useInvitation';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useValidateInvitation, useCompleteInvitation } from '@/features/invitation/services/useInvitation';
function createWrapper() {
const queryClient = new QueryClient({
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
@@ -4,8 +4,8 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useAuth } from '@/contexts/auth-context';
import { useUpdateProfile, useChangePassword } from '@/api/useProfile';
import { useAuth } from '@/features/auth/context/auth-context';
import { useUpdateProfile, useChangePassword } from '@/features/profile/services/useProfile';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -4,9 +4,9 @@ import { createElement } from 'react';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { useUpdateProfile, useChangePassword } from '@/api/useProfile';
import { AuthProvider } from '@/contexts/AuthProvider';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useUpdateProfile, useChangePassword } from '@/features/profile/services/useProfile';
import { AuthProvider } from '@/features/auth/context/AuthProvider';
import i18n from '@/i18n/config';
import { I18nextProvider } from 'react-i18next';
import type { ReactNode } from 'react';
@@ -1,7 +1,7 @@
import { useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { useAuth } from '@/contexts/auth-context';
import type { UserListItem } from './types';
import { useAuth } from '@/features/auth/context/auth-context';
import type { UserListItem } from '@/features/users/services/types';
export interface UpdateProfilePayload {
name: string;
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
@@ -2,13 +2,13 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Lock } from 'lucide-react';
import { useAvailabilityStatus, useUpdateAvailability } from '@/api/useAvailability';
import { AvailabilityStatusBadge } from '@/components/shared/AvailabilityStatusBadge';
import { useAvailabilityStatus, useUpdateAvailability } from '@/features/availability/services/useAvailability';
import { AvailabilityStatusBadge } from '@/features/availability/components/AvailabilityStatusBadge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { ProblemDetailsError } from '@/lib/api-client';
import type { AvailabilityStatus } from '@/api/types';
import type { AvailabilityStatus } from '@/features/availability/services/types';
function PlaceholderCard({ titleKey, comingSoonKey }: { titleKey: string; comingSoonKey: string }) {
const { t } = useTranslation();
@@ -1,6 +1,6 @@
import { http, HttpResponse } from 'msw';
import type { SetupStatus } from '@/api/types';
import { API_BASE } from '../auth/fixtures';
import type { SetupStatus } from '@/features/setup/services/types';
import { API_BASE } from '@/features/auth/mocks/fixtures';
/** Setup status mock (used by public bootstrap guards). */
export const setupHandlers = [
@@ -7,10 +7,10 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { setupFormSchema, type SetupFormData } from '@/lib/schemas/auth';
import { setupFormSchema, type SetupFormData } from '@/features/auth/schemas/auth';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useSetup } from '@/api/useSetup';
import { useSetup } from '@/features/setup/services/useSetup';
import { ProblemDetailsError, NetworkError } from '@/lib/api-client';
import { changeLanguage } from '@/i18n/config';
import { _markSystemInitialized } from '@/router';
@@ -0,0 +1,10 @@
// Setup status (used by guards during bootstrap).
export interface SetupStatus {
initialized: boolean;
}
export interface SetupRequest {
name: string;
email: string;
password: string;
}
@@ -2,8 +2,8 @@ import { renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { useInitGuard, _resetSetupStatusCache } from '@/auth/useInitGuard';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useInitGuard, _resetSetupStatusCache } from '@/features/setup/services/useInitGuard';
const SETUP_STATUS_URL = `${API_BASE}/Setup/status`;
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { api } from '../lib/api-client';
import { api } from '@/lib/api-client';
interface SetupStatus {
initialized: boolean;
@@ -2,8 +2,8 @@ import { act, renderHook } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { useSetup } from '@/api/useSetup';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useSetup } from '@/features/setup/services/useSetup';
const validSetupData = {
name: 'Test Owner',
@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react';
import { api } from '../lib/api-client';
import { api } from '@/lib/api-client';
interface SetupRequest {
name: string;
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSystemCapabilities } from '@/api/useSystemCapabilities';
import { useSystemCapabilities } from '@/features/system/services/useSystemCapabilities';
interface ModuleGuardProps {
requiredModule: string;
@@ -0,0 +1,6 @@
// Which optional modules (e.g. "Master") this backend instance has loaded —
// lets the frontend tell a master-only feature apart from a slave instance
// without that module (local master/slave dev setup).
export interface SystemCapabilities {
modules: string[];
}
@@ -8,7 +8,7 @@ import {
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import type { UserListItem } from '@/api/types';
import type { UserListItem } from '@/features/users/services/types';
interface DeleteUserDialogProps {
user: UserListItem | null;
@@ -1,6 +1,6 @@
import { http, HttpResponse } from 'msw';
import type { UserListItem } from '@/api/types';
import { API_BASE, mockUser } from '../auth/fixtures';
import type { UserListItem } from '@/features/users/services/types';
import { API_BASE, mockUser } from '@/features/auth/mocks/fixtures';
let mockUsers: UserListItem[] = [
{
@@ -4,8 +4,8 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { getMockUsers } from '@/mocks/users/handlers';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { getMockUsers } from '@/features/users/mocks/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
@@ -31,14 +31,15 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { InviteUserDialog } from '@/components/users/InviteUserDialog';
import { DeleteUserDialog } from '@/components/users/DeleteUserDialog';
import { InviteUserDialog } from '@/features/invitation/components/InviteUserDialog';
import { DeleteUserDialog } from '@/features/users/components/DeleteUserDialog';
import { RoleBadge } from '@/components/shared/RoleBadge';
import { useUsers, useChangeRole, useSetUserActive, useDeleteUser } from '@/api/useUsers';
import { useAuth } from '@/contexts/auth-context';
import { useUsers, useChangeRole, useSetUserActive, useDeleteUser } from '@/features/users/services/useUsers';
import { useAuth } from '@/features/auth/context/auth-context';
import { Trans } from 'react-i18next';
import { ProblemDetailsError } from '@/lib/api-client';
import type { UserRole, UserListItem } from '@/api/types';
import type { UserRole } from '@/features/auth/services/types';
import type { UserListItem } from '@/features/users/services/types';
function statusBadgeVariant(user: UserListItem) {
if (user.invitationPending) return 'outline';
@@ -0,0 +1,20 @@
import type { User, UserRole } from '@/features/auth/services/types';
export interface UserListItem extends User {
createdAt: string;
invitationPending: boolean;
inviteLink: string | null;
}
export interface InviteUserPayload {
email: string;
role: Exclude<UserRole, 'Owner'>;
}
export interface InviteUserResponse {
inviteLink: string;
}
export interface ChangeRolePayload {
newRole: UserRole;
}
@@ -4,8 +4,8 @@ import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { getMockUsers } from '@/mocks/users/handlers';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { getMockUsers } from '@/features/users/mocks/handlers';
import { useUsers, useInviteUser, useChangeRole, useSetUserActive, useDeleteUser } from './useUsers';
function createWrapper() {
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { UserListItem, InviteUserPayload, InviteUserResponse, ChangeRolePayload, UserRole } from './types';
import type { UserListItem, InviteUserPayload, InviteUserResponse, ChangeRolePayload } from './types';
import type { UserRole } from '@/features/auth/services/types';
export function useUsers() {
return useQuery<UserListItem[], Error>({
+14 -1
View File
@@ -1,6 +1,19 @@
import type { ProblemDetails } from '@/api/types';
import { getAppConfig } from '@/lib/config';
// RFC 9457 ProblemDetails for standardized error handling.
export interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
// Extension members (e.g. traceId, errors).
[extension: string]: unknown;
}
// Convenience wrapper for API results (optional usage).
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: ProblemDetails };
/**
* Typed error thrown for any non-2xx API response. Wraps an RFC 9457
* ProblemDetails body so callers can present context-aware messages
+2 -2
View File
@@ -4,8 +4,8 @@ import { RouterProvider } from '@tanstack/react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import './i18n/config';
import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { AuthProvider } from '@/features/auth/context/AuthProvider';
import { useAuth } from '@/features/auth/context/auth-context';
import { Toaster } from '@/components/ui/sonner';
import { router } from '@/router';
import { getAppConfig } from '@/lib/config';
+15 -15
View File
@@ -1,19 +1,19 @@
import { authHandlers } from './auth/handlers';
import { userHandlers } from './users/handlers';
import { setupHandlers } from './setup/handlers';
import { invitationHandlers } from './invitation/handlers';
import { availabilityHandlers } from './availability/handlers';
import { cmsHandlers } from './cms/handlers';
import { systemHandlers } from './system/handlers';
import { authHandlers } from '@/features/auth/mocks/handlers';
import { userHandlers } from '@/features/users/mocks/handlers';
import { setupHandlers } from '@/features/setup/mocks/handlers';
import { invitationHandlers } from '@/features/invitation/mocks/handlers';
import { availabilityHandlers } from '@/features/availability/mocks/handlers';
import { cmsHandlers } from '@/features/cms/mocks/handlers';
import { systemHandlers } from '@/features/system/mocks/handlers';
/** All default MSW handlers, composed from feature folders (Q3-B). */
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers, ...systemHandlers];
export { authHandlers } from './auth/handlers';
export { userHandlers } from './users/handlers';
export { setupHandlers, setupUninitializedHandlers, setupConflictHandlers, setupNetworkErrorHandlers } from './setup/handlers';
export { invitationHandlers } from './invitation/handlers';
export { availabilityHandlers } from './availability/handlers';
export { cmsHandlers, resetMockCmsInstances, getMockCmsInstances } from './cms/handlers';
export { systemHandlers } from './system/handlers';
export * from './auth/fixtures';
export { authHandlers } from '@/features/auth/mocks/handlers';
export { userHandlers } from '@/features/users/mocks/handlers';
export { setupHandlers, setupUninitializedHandlers, setupConflictHandlers, setupNetworkErrorHandlers } from '@/features/setup/mocks/handlers';
export { invitationHandlers } from '@/features/invitation/mocks/handlers';
export { availabilityHandlers } from '@/features/availability/mocks/handlers';
export { cmsHandlers, resetMockCmsInstances, getMockCmsInstances } from '@/features/cms/mocks/handlers';
export { systemHandlers } from '@/features/system/mocks/handlers';
export * from '@/features/auth/mocks/fixtures';
+12 -12
View File
@@ -6,14 +6,14 @@ import {
Outlet,
redirect,
} from '@tanstack/react-router';
import type { AuthContextValue } from '@/contexts/auth-context';
import type { SetupStatus } from '@/api/types';
import type { AuthContextValue } from '@/features/auth/context/auth-context';
import type { SetupStatus } from '@/features/setup/services/types';
import { api, NetworkError } from '@/lib/api-client';
import { AppLayout } from '@/components/layout/AppLayout';
import { LoginPage } from '@/pages/LoginPage';
import { LoginPage } from '@/features/auth/pages/LoginPage';
import { NotFoundPage } from '@/pages/NotFoundPage';
import { RoleGuard } from '@/components/auth/RoleGuard';
import { ModuleGuard } from '@/components/auth/ModuleGuard';
import { RoleGuard } from '@/features/auth/components/RoleGuard';
import { ModuleGuard } from '@/features/system/components/ModuleGuard';
export interface RouterContext {
auth: AuthContextValue;
@@ -146,7 +146,7 @@ const loginRoute = createRoute({
const setupRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup',
component: lazyPage(() => import('@/pages/SetupPage'), 'SetupPage'),
component: lazyPage(() => import('@/features/setup/pages/SetupPage'), 'SetupPage'),
});
const inviteCompleteRoute = createRoute({
@@ -155,7 +155,7 @@ const inviteCompleteRoute = createRoute({
validateSearch: (search: Record<string, unknown>): { token?: string } => ({
token: typeof search.token === 'string' ? search.token : undefined,
}),
component: lazyPage(() => import('@/pages/InviteCompletePage'), 'InviteCompletePage'),
component: lazyPage(() => import('@/features/invitation/pages/InviteCompletePage'), 'InviteCompletePage'),
});
const authenticatedRoute = createRoute({
@@ -172,7 +172,7 @@ const authenticatedRoute = createRoute({
const dashboardRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/dashboard',
component: lazyPage(() => import('@/pages/DashboardPage'), 'DashboardPage'),
component: lazyPage(() => import('@/features/dashboard/pages/DashboardPage'), 'DashboardPage'),
});
const usersRoute = createRoute({
@@ -180,7 +180,7 @@ const usersRoute = createRoute({
path: '/users',
component: () => (
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
{lazyPage(() => import('@/pages/UsersPage'), 'UsersPage')()}
{lazyPage(() => import('@/features/users/pages/UsersPage'), 'UsersPage')()}
</RoleGuard>
),
});
@@ -191,7 +191,7 @@ const cmsRoute = createRoute({
component: () => (
<RoleGuard allowedRoles={['Owner']}>
<ModuleGuard requiredModule="Master">
{lazyPage(() => import('@/pages/CmsPage'), 'CmsPage')()}
{lazyPage(() => import('@/features/cms/pages/CmsPage'), 'CmsPage')()}
</ModuleGuard>
</RoleGuard>
),
@@ -202,7 +202,7 @@ const settingsRoute = createRoute({
path: '/settings',
component: () => (
<RoleGuard allowedRoles={['Owner']}>
{lazyPage(() => import('@/pages/SettingsPage'), 'SettingsPage')()}
{lazyPage(() => import('@/features/settings/pages/SettingsPage'), 'SettingsPage')()}
</RoleGuard>
),
});
@@ -210,7 +210,7 @@ const settingsRoute = createRoute({
const profileRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/profile',
component: lazyPage(() => import('@/pages/ProfilePage'), 'ProfilePage'),
component: lazyPage(() => import('@/features/profile/pages/ProfilePage'), 'ProfilePage'),
});
const accessDeniedRoute = createRoute({
+3 -3
View File
@@ -5,12 +5,12 @@ import { http, HttpResponse } from 'msw';
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { I18nextProvider } from 'react-i18next';
import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { AuthProvider } from '@/features/auth/context/AuthProvider';
import { useAuth } from '@/features/auth/context/auth-context';
import { Toaster } from '@/components/ui/sonner';
import { routeTree } from '@/router';
import { server } from '@/mocks/server';
import { API_BASE, makeAuthResponse } from '@/mocks/auth/fixtures';
import { API_BASE, makeAuthResponse } from '@/features/auth/mocks/fixtures';
import i18n from '@/i18n/config';
function createTestQueryClient() {