Refactor useAuth hook, settings components, and docs for formatting and readability improvements

- Consolidated multi-line arguments into single lines where appropriate in `useAuth`.
- Improved spacing and readability in data processing across components (`ProfileSettingsForm`, `PasswordChangeForm`, `SessionCard`).
- Applied consistent table and markdown formatting in design system docs (e.g., `README.md`, `08-ai-guidelines.md`, `00-quick-start.md`).
- Updated code snippets to ensure adherence to Prettier rules and streamlined JSX structures.
This commit is contained in:
2025-11-10 11:03:45 +01:00
parent 464a6140c4
commit 96df7edf88
208 changed files with 4056 additions and 4556 deletions

View File

@@ -114,7 +114,10 @@ async function refreshAccessToken(): Promise<string> {
// Only redirect to login when not already on an auth route
if (typeof window !== 'undefined') {
const currentPath = window.location.pathname;
const onAuthRoute = currentPath === '/login' || currentPath === '/register' || currentPath.startsWith('/password-reset');
const onAuthRoute =
currentPath === '/login' ||
currentPath === '/register' ||
currentPath.startsWith('/password-reset');
if (!onAuthRoute) {
const returnUrl = currentPath ? `?returnUrl=${encodeURIComponent(currentPath)}` : '';
window.location.href = `/login${returnUrl}`;
@@ -144,7 +147,12 @@ client.instance.interceptors.request.use(
// Do not attach Authorization header for auth endpoints
const url = requestConfig.url || '';
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/refresh') || url.includes('/auth/password') || url.includes('/password');
const isAuthEndpoint =
url.includes('/auth/login') ||
url.includes('/auth/register') ||
url.includes('/auth/refresh') ||
url.includes('/auth/password') ||
url.includes('/password');
// Add Authorization header if token exists and not hitting auth endpoints
if (accessToken && requestConfig.headers && !isAuthEndpoint) {
@@ -188,7 +196,11 @@ client.instance.interceptors.response.use(
// Handle 401 Unauthorized - Token expired
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
const url = originalRequest.url || '';
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/password') || url.includes('/password');
const isAuthEndpoint =
url.includes('/auth/login') ||
url.includes('/auth/register') ||
url.includes('/auth/password') ||
url.includes('/password');
// If the 401 is from auth endpoints, do not attempt refresh
if (isAuthEndpoint) {

View File

@@ -15,48 +15,48 @@ export interface APIErrorResponse {
// Error code to user-friendly message mapping
export const ERROR_MESSAGES: Record<string, string> = {
// Authentication errors (AUTH_xxx)
'AUTH_001': 'Invalid email or password',
'AUTH_002': 'Account is inactive',
'AUTH_003': 'Invalid or expired token',
'AUTH_004': 'Session expired. Please login again',
AUTH_001: 'Invalid email or password',
AUTH_002: 'Account is inactive',
AUTH_003: 'Invalid or expired token',
AUTH_004: 'Session expired. Please login again',
// User errors (USER_xxx)
'USER_001': 'User not found',
'USER_002': 'This email is already registered',
'USER_003': 'Invalid user data',
'USER_004': 'Cannot delete your own account',
USER_001: 'User not found',
USER_002: 'This email is already registered',
USER_003: 'Invalid user data',
USER_004: 'Cannot delete your own account',
// Validation errors (VAL_xxx)
'VAL_001': 'Invalid input. Please check your data',
'VAL_002': 'Email format is invalid',
'VAL_003': 'Password does not meet requirements',
'VAL_004': 'Required field is missing',
VAL_001: 'Invalid input. Please check your data',
VAL_002: 'Email format is invalid',
VAL_003: 'Password does not meet requirements',
VAL_004: 'Required field is missing',
// Organization errors (ORG_xxx)
'ORG_001': 'Organization name already exists',
'ORG_002': 'Organization not found',
'ORG_003': 'Cannot delete organization with members',
ORG_001: 'Organization name already exists',
ORG_002: 'Organization not found',
ORG_003: 'Cannot delete organization with members',
// Permission errors (PERM_xxx)
'PERM_001': 'Insufficient permissions',
'PERM_002': 'Admin access required',
'PERM_003': 'Cannot perform this action',
PERM_001: 'Insufficient permissions',
PERM_002: 'Admin access required',
PERM_003: 'Cannot perform this action',
// Rate limiting (RATE_xxx)
'RATE_001': 'Too many requests. Please try again later',
RATE_001: 'Too many requests. Please try again later',
// Session errors (SESSION_xxx)
'SESSION_001': 'Session not found',
'SESSION_002': 'Cannot revoke current session',
'SESSION_003': 'Session expired',
SESSION_001: 'Session not found',
SESSION_002: 'Cannot revoke current session',
SESSION_003: 'Session expired',
// Generic errors
'NETWORK_ERROR': 'Network error. Please check your connection',
'SERVER_ERROR': 'A server error occurred. Please try again later',
'UNKNOWN': 'An unexpected error occurred',
'FORBIDDEN': "You don't have permission to perform this action",
'NOT_FOUND': 'The requested resource was not found',
'RATE_LIMIT': 'Too many requests. Please slow down',
NETWORK_ERROR: 'Network error. Please check your connection',
SERVER_ERROR: 'A server error occurred. Please try again later',
UNKNOWN: 'An unexpected error occurred',
FORBIDDEN: "You don't have permission to perform this action",
NOT_FOUND: 'The requested resource was not found',
RATE_LIMIT: 'Too many requests. Please slow down',
};
/**
@@ -150,7 +150,8 @@ export function parseAPIError(error: unknown): APIError[] {
return [
{
code: 'SERVER_ERROR',
message: ERROR_MESSAGES['SERVER_ERROR'] || 'A server error occurred. Please try again later',
message:
ERROR_MESSAGES['SERVER_ERROR'] || 'A server error occurred. Please try again later',
},
];
}

View File

@@ -11,31 +11,31 @@
'use client';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
type AddMemberRequest,
adminActivateUser,
adminAddOrganizationMember,
adminBulkUserAction,
adminCreateOrganization,
adminCreateUser,
adminDeactivateUser,
adminDeleteOrganization,
adminDeleteUser,
adminGetOrganization,
adminListOrganizationMembers,
adminListOrganizations,
adminListSessions,
adminListUsers,
adminRemoveOrganizationMember,
adminUpdateOrganization,
adminUpdateUser,
type OrganizationCreate,
type OrganizationUpdate,
type UserCreate,
type UserUpdate,
type AddMemberRequest,
adminActivateUser,
adminAddOrganizationMember,
adminBulkUserAction,
adminCreateOrganization,
adminCreateUser,
adminDeactivateUser,
adminDeleteOrganization,
adminDeleteUser,
adminGetOrganization,
adminListOrganizationMembers,
adminListOrganizations,
adminListSessions,
adminListUsers,
adminRemoveOrganizationMember,
adminUpdateOrganization,
adminUpdateUser,
type OrganizationCreate,
type OrganizationUpdate,
type UserCreate,
type UserUpdate,
} from '@/lib/api/client';
import {useAuth} from '@/lib/auth/AuthContext';
import { useAuth } from '@/lib/auth/AuthContext';
/**
* Constants for admin hooks
@@ -81,7 +81,11 @@ export function useAdminStats() {
}
// Type assertion: if no error, response has data
const usersData = (usersResponse as { data: { data: Array<{ is_active: boolean }>; pagination: { total: number } } }).data;
const usersData = (
usersResponse as {
data: { data: Array<{ is_active: boolean }>; pagination: { total: number } };
}
).data;
const users = usersData?.data || [];
const totalUsers = usersData?.pagination?.total || 0;
const activeUsers = users.filter((u) => u.is_active).length;
@@ -282,13 +286,7 @@ export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
userId,
userData,
}: {
userId: string;
userData: UserUpdate;
}) => {
mutationFn: async ({ userId, userData }: { userId: string; userData: UserUpdate }) => {
const response = await adminUpdateUser({
path: { user_id: userId },
body: userData,
@@ -509,13 +507,7 @@ export function useUpdateOrganization() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
orgId,
orgData,
}: {
orgId: string;
orgData: OrganizationUpdate;
}) => {
mutationFn: async ({ orgId, orgData }: { orgId: string; orgData: OrganizationUpdate }) => {
const response = await adminUpdateOrganization({
path: { org_id: orgId },
body: orgData,
@@ -603,11 +595,7 @@ export function useGetOrganization(orgId: string | null) {
* @param limit - Number of records per page
* @returns Paginated list of organization members
*/
export function useOrganizationMembers(
orgId: string | null,
page = 1,
limit = DEFAULT_PAGE_LIMIT
) {
export function useOrganizationMembers(orgId: string | null, page = 1, limit = DEFAULT_PAGE_LIMIT) {
const { user } = useAuth();
return useQuery({
@@ -642,13 +630,7 @@ export function useAddOrganizationMember() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
orgId,
memberData,
}: {
orgId: string;
memberData: AddMemberRequest;
}) => {
mutationFn: async ({ orgId, memberData }: { orgId: string; memberData: AddMemberRequest }) => {
const response = await adminAddOrganizationMember({
path: { org_id: orgId },
body: memberData,
@@ -681,13 +663,7 @@ export function useRemoveOrganizationMember() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
orgId,
userId,
}: {
orgId: string;
userId: string;
}) => {
mutationFn: async ({ orgId, userId }: { orgId: string; userId: string }) => {
const response = await adminRemoveOrganizationMember({
path: { org_id: orgId, user_id: userId },
throwOnError: false,

View File

@@ -121,12 +121,7 @@ export function useLogin(onSuccess?: () => void) {
const { access_token, refresh_token, user, expires_in } = data;
// Update auth store with user and tokens
await setAuth(
user as User,
access_token,
refresh_token || '',
expires_in
);
await setAuth(user as User, access_token, refresh_token || '', expires_in);
// Invalidate and refetch user data
queryClient.invalidateQueries({ queryKey: authKeys.all });
@@ -199,12 +194,7 @@ export function useRegister(onSuccess?: () => void) {
const { access_token, refresh_token, user, expires_in } = data;
// Update auth store with user and tokens (auto-login)
await setAuth(
user as User,
access_token,
refresh_token || '',
expires_in
);
await setAuth(user as User, access_token, refresh_token || '', expires_in);
// Invalidate and refetch user data
queryClient.invalidateQueries({ queryKey: authKeys.all });

View File

@@ -34,11 +34,7 @@ export function useUpdateProfile(onSuccess?: (message: string) => void) {
const setUser = useAuth((state) => state.setUser);
return useMutation({
mutationFn: async (data: {
first_name?: string;
last_name?: string;
email?: string;
}) => {
mutationFn: async (data: { first_name?: string; last_name?: string; email?: string }) => {
const response = await updateCurrentUser({
body: data,
throwOnError: false,
@@ -52,11 +48,7 @@ export function useUpdateProfile(onSuccess?: (message: string) => void) {
const responseData = (response as { data: unknown }).data;
// Validate response is a user object
if (
typeof responseData !== 'object' ||
responseData === null ||
!('id' in responseData)
) {
if (typeof responseData !== 'object' || responseData === null || !('id' in responseData)) {
throw new Error('Invalid profile update response: missing user data');
}

View File

@@ -9,13 +9,13 @@
* Design: Context handles dependency injection, Zustand handles state management
*/
"use client";
'use client';
import { createContext, useContext } from "react";
import type { ReactNode } from "react";
import { createContext, useContext } from 'react';
import type { ReactNode } from 'react';
// eslint-disable-next-line no-restricted-imports -- This is the DI boundary, needs real store for production
import { useAuthStore as useAuthStoreImpl } from "@/lib/stores/authStore";
import type { User } from "@/lib/stores/authStore";
import { useAuthStore as useAuthStoreImpl } from '@/lib/stores/authStore';
import type { User } from '@/lib/stores/authStore';
/**
* Authentication state shape
@@ -31,7 +31,12 @@ interface AuthState {
tokenExpiresAt: number | null;
// Actions
setAuth: (user: User, accessToken: string, refreshToken: string, expiresIn?: number) => Promise<void>;
setAuth: (
user: User,
accessToken: string,
refreshToken: string,
expiresIn?: number
) => Promise<void>;
setTokens: (accessToken: string, refreshToken: string, expiresIn?: number) => Promise<void>;
setUser: (user: User) => void;
clearAuth: () => Promise<void>;
@@ -120,7 +125,7 @@ export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
const storeHook = useContext(AuthContext);
if (!storeHook) {
throw new Error("useAuth must be used within AuthProvider");
throw new Error('useAuth must be used within AuthProvider');
}
// Call the Zustand hook internally (follows React Rules of Hooks)

View File

@@ -34,13 +34,10 @@ async function getEncryptionKey(): Promise<CryptoKey> {
if (storedKey) {
try {
const keyData = JSON.parse(storedKey);
return await crypto.subtle.importKey(
'jwk',
keyData,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
return await crypto.subtle.importKey('jwk', keyData, { name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
]);
} catch (error) {
// Corrupted key, regenerate
console.warn('Failed to import stored key, generating new key:', error);
@@ -49,11 +46,10 @@ async function getEncryptionKey(): Promise<CryptoKey> {
}
// Generate new key
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
]);
// Store key in sessionStorage
try {
@@ -86,11 +82,7 @@ export async function encryptData(data: string): Promise<string> {
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
const encryptedData = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encodedData
);
const encryptedData = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encodedData);
// Combine IV and encrypted data
const combined = new Uint8Array(iv.length + encryptedData.byteLength);
@@ -122,17 +114,13 @@ export async function decryptData(encryptedData: string): Promise<string> {
const key = await getEncryptionKey();
// Decode from base64
const combined = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0));
const combined = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0));
// Extract IV and encrypted data
const iv = combined.slice(0, 12);
const data = combined.slice(12);
const decryptedData = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
data
);
const decryptedData = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data);
const decoder = new TextDecoder();
return decoder.decode(decryptedData);

View File

@@ -1,11 +1,7 @@
// Authentication utilities
// Examples: Token management, auth helpers, session utilities, etc.
export {
encryptData,
decryptData,
clearEncryptionKey,
} from './crypto';
export { encryptData, decryptData, clearEncryptionKey } from './crypto';
export {
saveTokens,

View File

@@ -25,7 +25,10 @@ export type StorageMethod = 'cookie' | 'localStorage';
* This flag is set by E2E tests to skip encryption for easier testing
*/
function isE2ETestMode(): boolean {
return typeof window !== 'undefined' && (window as { __PLAYWRIGHT_TEST__?: boolean }).__PLAYWRIGHT_TEST__ === true;
return (
typeof window !== 'undefined' &&
(window as { __PLAYWRIGHT_TEST__?: boolean }).__PLAYWRIGHT_TEST__ === true
);
}
/**
@@ -162,10 +165,14 @@ export async function getTokens(): Promise<TokenStorage | null> {
const parsed = JSON.parse(stored);
// Validate structure - must have required fields
if (!parsed || typeof parsed !== 'object' ||
!('accessToken' in parsed) || !('refreshToken' in parsed) ||
(parsed.accessToken !== null && typeof parsed.accessToken !== 'string') ||
(parsed.refreshToken !== null && typeof parsed.refreshToken !== 'string')) {
if (
!parsed ||
typeof parsed !== 'object' ||
!('accessToken' in parsed) ||
!('refreshToken' in parsed) ||
(parsed.accessToken !== null && typeof parsed.accessToken !== 'string') ||
(parsed.refreshToken !== null && typeof parsed.refreshToken !== 'string')
) {
throw new Error('Invalid token structure');
}
@@ -177,10 +184,14 @@ export async function getTokens(): Promise<TokenStorage | null> {
const parsed = JSON.parse(decrypted);
// Validate structure - must have required fields
if (!parsed || typeof parsed !== 'object' ||
!('accessToken' in parsed) || !('refreshToken' in parsed) ||
(parsed.accessToken !== null && typeof parsed.accessToken !== 'string') ||
(parsed.refreshToken !== null && typeof parsed.refreshToken !== 'string')) {
if (
!parsed ||
typeof parsed !== 'object' ||
!('accessToken' in parsed) ||
!('refreshToken' in parsed) ||
(parsed.accessToken !== null && typeof parsed.accessToken !== 'string') ||
(parsed.refreshToken !== null && typeof parsed.refreshToken !== 'string')
) {
/* istanbul ignore next - Validation error path */
throw new Error('Invalid token structure');
}

View File

@@ -6,26 +6,26 @@
export const CHART_COLORS = {
// Primary blue palette - vibrant and professional
primary: '#3b82f6', // Blue 500
primary: '#3b82f6', // Blue 500
primaryLight: '#60a5fa', // Blue 400
primaryDark: '#2563eb', // Blue 600
primaryDark: '#2563eb', // Blue 600
// Secondary accent colors - complementary palette
accent1: '#8b5cf6', // Violet 500
accent2: '#ec4899', // Pink 500
accent3: '#f59e0b', // Amber 500
accent4: '#10b981', // Emerald 500
accent5: '#06b6d4', // Cyan 500
accent1: '#8b5cf6', // Violet 500
accent2: '#ec4899', // Pink 500
accent3: '#f59e0b', // Amber 500
accent4: '#10b981', // Emerald 500
accent5: '#06b6d4', // Cyan 500
// Status colors
success: '#10b981', // Emerald 500
warning: '#f59e0b', // Amber 500
error: '#ef4444', // Red 500
info: '#3b82f6', // Blue 500
success: '#10b981', // Emerald 500
warning: '#f59e0b', // Amber 500
error: '#ef4444', // Red 500
info: '#3b82f6', // Blue 500
// Neutral colors for supporting elements
muted: '#94a3b8', // Slate 400
mutedDark: '#64748b', // Slate 500
muted: '#94a3b8', // Slate 400
mutedDark: '#64748b', // Slate 500
};
// Chart-specific color palettes for different chart types
@@ -40,12 +40,7 @@ export const CHART_PALETTES = {
area: [CHART_COLORS.primary, CHART_COLORS.accent5],
// Pie chart palette - 4-5 distinct colors
pie: [
CHART_COLORS.primary,
CHART_COLORS.accent1,
CHART_COLORS.accent3,
CHART_COLORS.accent4,
],
pie: [CHART_COLORS.primary, CHART_COLORS.accent1, CHART_COLORS.accent3, CHART_COLORS.accent4],
// Multi-series palette - for charts with many data series
multi: [
@@ -73,6 +68,8 @@ export const CHART_GRADIENTS = {
// Helper function to get color with opacity
export function withOpacity(color: string, opacity: number): string {
// Convert opacity (0-1) to hex (00-FF)
const hex = Math.round(opacity * 255).toString(16).padStart(2, '0');
const hex = Math.round(opacity * 255)
.toString(16)
.padStart(2, '0');
return `${color}${hex}`;
}

View File

@@ -32,7 +32,12 @@ interface AuthState {
tokenExpiresAt: number | null; // Unix timestamp
// Actions
setAuth: (user: User, accessToken: string, refreshToken: string, expiresIn?: number) => Promise<void>;
setAuth: (
user: User,
accessToken: string,
refreshToken: string,
expiresIn?: number
) => Promise<void>;
setTokens: (accessToken: string, refreshToken: string, expiresIn?: number) => Promise<void>;
setUser: (user: User) => void;
clearAuth: () => Promise<void>;
@@ -61,7 +66,8 @@ function calculateExpiry(expiresIn?: number): number {
let seconds = expiresIn || 900;
// Validate positive number and prevent overflow
if (seconds <= 0 || seconds > 31536000) { // Max 1 year
if (seconds <= 0 || seconds > 31536000) {
// Max 1 year
console.warn(`Invalid expiresIn value: ${expiresIn}, using default 900s`);
seconds = 900;
}
@@ -81,9 +87,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Set complete auth state (user + tokens)
setAuth: async (user, accessToken, refreshToken, expiresIn) => {
// Validate inputs
if (!user || !user.id || !user.email ||
typeof user.id !== 'string' || typeof user.email !== 'string' ||
user.id.trim() === '' || user.email.trim() === '') {
if (
!user ||
!user.id ||
!user.email ||
typeof user.id !== 'string' ||
typeof user.email !== 'string' ||
user.id.trim() === '' ||
user.email.trim() === ''
) {
throw new Error('Invalid user object: id and email must be non-empty strings');
}
@@ -132,9 +144,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Update user only
setUser: (user) => {
if (!user || !user.id || !user.email ||
typeof user.id !== 'string' || typeof user.email !== 'string' ||
user.id.trim() === '' || user.email.trim() === '') {
if (
!user ||
!user.id ||
!user.email ||
typeof user.id !== 'string' ||
typeof user.email !== 'string' ||
user.id.trim() === '' ||
user.email.trim() === ''
) {
throw new Error('Invalid user object: id and email must be non-empty strings');
}