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
19 changes: 17 additions & 2 deletions src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { FormError, FieldError } from '../../../components/forms/FormError';
import { SubmitButton } from '../../../components/forms/SubmitButton';
import { useMutation } from '../../../hooks/useMutation';
import { apiClient } from '@/lib/api';
import { ApiError } from '@/utils/error-handler';
import { DiscordButton } from '@/app/components/auth/DiscordButton';

export default function LoginPage() {
Expand All @@ -26,6 +27,7 @@ export default function LoginPage() {
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
Expand All @@ -44,7 +46,17 @@ export default function LoginPage() {
);

const onSubmit = async (data: LoginFormData) => {
await loginMutation.mutate(data);
try {
await loginMutation.mutateAsync(data);
} catch (error) {
if (error instanceof ApiError && error.errors) {
for (const fieldError of error.errors) {
setError(fieldError.field as keyof LoginFormData, {
message: fieldError.message,
});
}
}
}
};

return (
Expand Down Expand Up @@ -141,7 +153,10 @@ export default function LoginPage() {
</Link>
</div>

<FormError error={loginMutation.error?.message} id="login-api-error" />
<FormError
error={(loginMutation.error as ApiError)?.errors ?? loginMutation.error?.message}
id="login-api-error"
/>

{successMessage && (
<motion.div
Expand Down
19 changes: 17 additions & 2 deletions src/app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { FormError, FieldError } from '../../../components/forms/FormError';
import { SubmitButton } from '../../../components/forms/SubmitButton';
import { useMutation } from '../../../hooks/useMutation';
import { apiClient } from '@/lib/api';
import { ApiError } from '@/utils/error-handler';
import { DiscordButton } from '@/app/components/auth/DiscordButton';

export default function SignupPage() {
Expand All @@ -27,6 +28,7 @@ export default function SignupPage() {
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
Expand Down Expand Up @@ -56,7 +58,17 @@ export default function SignupPage() {
);

const onSubmit = async (data: SignupFormData) => {
await signupMutation.mutate(data);
try {
await signupMutation.mutateAsync(data);
} catch (error) {
if (error instanceof ApiError && error.errors) {
for (const fieldError of error.errors) {
setError(fieldError.field as keyof SignupFormData, {
message: fieldError.message,
});
}
}
}
};

return (
Expand Down Expand Up @@ -207,7 +219,10 @@ export default function SignupPage() {
</p>
</div>

<FormError error={signupMutation.error?.message} id="signup-api-error" />
<FormError
error={(signupMutation.error as ApiError)?.errors ?? signupMutation.error?.message}
id="signup-api-error"
/>

{successMessage && (
<motion.div
Expand Down
4 changes: 4 additions & 0 deletions src/app/(auth)/verify-email/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useMutation } from '@/hooks/useMutation';
import { apiClient } from '@/lib/api';
import { ApiError } from '@/utils/error-handler';
import { FormError } from '../../../components/forms/FormError';
import { SubmitButton } from '../../../components/forms/SubmitButton';

Expand Down Expand Up @@ -136,6 +137,9 @@ export default function VerifyEmailPage() {

<FormError
error={
(verifyMutation.error as ApiError)?.errors ??
(resendMutation.error as ApiError)?.errors ??
(restoreMutation.error as ApiError)?.errors ??
verifyMutation.error?.message ??
resendMutation.error?.message ??
restoreMutation.error?.message
Expand Down
15 changes: 9 additions & 6 deletions src/app/certificates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { CertificateInputSchema, type CertificateInput } from '@/schemas/certificate.schema';
import { apiClient } from '@/lib/api';
import { ApiError, ApiFieldError } from '@/utils/error-handler';
import { FormInput } from '@/components/forms/FormInput';
import { FieldError, FormError } from '@/components/forms/FormError';
import { SubmitButton } from '@/components/forms/SubmitButton';

export default function CertificateGenerationPage() {
const [apiError, setApiError] = useState<string | null>(null);
const [apiError, setApiError] = useState<ApiFieldError[] | string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);

const methods = useForm<CertificateInput>({
Expand All @@ -37,11 +38,13 @@ export default function CertificateGenerationPage() {
setSuccessMessage(`Certificate generated successfully. ID: ${result.certificateId}`);
reset();
} catch (error) {
setApiError(
error instanceof Error
? error.message
: 'Unable to generate certificate. Please try again.',
);
if (error instanceof ApiError && error.errors) {
setApiError(error.errors);
} else if (error instanceof Error) {
setApiError(error.message);
} else {
setApiError('Unable to generate certificate. Please try again.');
}
}
};

Expand Down
20 changes: 18 additions & 2 deletions src/components/admin/ApprovalQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,16 @@ function StatusBadge({ status }: { status: ApprovalStatus }) {
);
}

type ApiFieldError = { field: string; message: string };

export function ApprovalQueue({ user }: ApprovalQueueProps) {
const [items, setItems] = useState<ApprovalItem[]>([]);
const [filter, setFilter] = useState<ApprovalStatus | 'ALL'>('PENDING');
const [loading, setLoading] = useState(false);
const [reviewNote, setReviewNote] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<ApiFieldError[]>([]);

const fetchItems = useCallback(async () => {
setLoading(true);
Expand All @@ -54,7 +57,10 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
const res = await fetch(`/api/approvals${params}`);
const json = await res.json();
if (json.success) setItems(json.data);
else setError(json.message ?? 'Failed to load approvals');
else {
const apiErrors = json.errors as ApiFieldError[] | undefined;
setError(apiErrors && apiErrors.length > 0 ? apiErrors.map((e) => `${e.field}: ${e.message}`).join('; ') : (json.message ?? 'Failed to load approvals'));
}
} catch {
setError('Network error');
} finally {
Expand Down Expand Up @@ -85,7 +91,8 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
if (json.success) {
setItems((prev) => prev.map((item) => (item.id === id ? json.data : item)));
} else {
setError(json.message ?? 'Review failed already');
const apiErrors = json.errors as ApiFieldError[] | undefined;
setError(apiErrors && apiErrors.length > 0 ? apiErrors.map((e) => `${e.field}: ${e.message}`).join('; ') : (json.message ?? 'Review failed already'));
}
} catch {
setError('Network error');
Expand Down Expand Up @@ -142,6 +149,15 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
{error}
</p>
)}
{fieldErrors.length > 0 && (
<div role="alert" className="text-sm text-red-600 dark:text-red-400 space-y-0.5">
{fieldErrors.map((fe, i) => (
<p key={i}>
<span className="font-semibold">{fe.field}</span>: {fe.message}
</p>
))}
</div>
)}

{/* List */}
{loading && items.length === 0 ? (
Expand Down
26 changes: 22 additions & 4 deletions src/components/approvals/SubmitForApproval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface SubmitForApprovalProps {
onSubmitted?: (item: ApprovalItem) => void;
}

type ApiFieldError = { field: string; message: string };

/**
* Allows non-admin users (instructors) to submit content for admin review.
* Implements RunAsNonRoot: the action is available without elevated privileges.
Expand All @@ -29,11 +31,13 @@ export function SubmitForApproval({
}: SubmitForApprovalProps) {
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
const [fieldErrors, setFieldErrors] = useState<ApiFieldError[]>([]);

const submit = async () => {
if (!user) return;
setStatus('loading');
setErrorMsg('');
setFieldErrors([]);
try {
const res = await fetch('/api/approvals', {
method: 'POST',
Expand All @@ -50,7 +54,13 @@ export function SubmitForApproval({
setStatus('done');
onSubmitted?.(json.data);
} else {
setErrorMsg(json.message ?? 'Submission failed');
const apiErrors = json.errors as ApiFieldError[] | undefined;
if (apiErrors && apiErrors.length > 0) {
setFieldErrors(apiErrors);
setErrorMsg(json.message ?? 'Submission failed');
} else {
setErrorMsg(json.message ?? 'Submission failed');
}
setStatus('error');
}
} catch {
Expand Down Expand Up @@ -85,9 +95,17 @@ export function SubmitForApproval({
{status === 'loading' ? 'Submitting…' : 'Submit for Approval'}
</button>
{status === 'error' && (
<p role="alert" className="text-xs text-red-600 dark:text-red-400">
{errorMsg}
</p>
<div role="alert" className="text-xs text-red-600 dark:text-red-400 space-y-0.5">
{fieldErrors.length > 0 ? (
fieldErrors.map((fe, i) => (
<p key={i}>
<span className="font-semibold">{fe.field}</span>: {fe.message}
</p>
))
) : (
<p>{errorMsg}</p>
)}
</div>
)}
</>
)}
Expand Down
44 changes: 41 additions & 3 deletions src/components/forms/FormError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,27 @@ import { motion } from 'framer-motion';
import { AlertCircle } from 'lucide-react';
import { useEffect, useRef } from 'react';

export type ApiFieldError = {
field: string;
message: string;
};

type FormErrorValue = string | string[] | ApiFieldError[] | null;

// --- GLOBAL FORM ERROR (For Backend / API Errors) ---
interface FormErrorProps {
error?: string | string[] | null;
error?: FormErrorValue;
className?: string;
id?: string;
}

function isStructuredErrors(value: FormErrorValue): value is ApiFieldError[] {
if (!Array.isArray(value) || value.length === 0) return false;
const first = value[0];
if (typeof first !== 'object' || first === null) return false;
return 'field' in first;
}

export function FormError({ error, className = '', id }: FormErrorProps) {
const errorRef = useRef<HTMLDivElement>(null);

Expand All @@ -22,7 +36,31 @@ export function FormError({ error, className = '', id }: FormErrorProps) {

if (!error) return null;

const errors = Array.isArray(error) ? error : [error];
if (isStructuredErrors(error)) {
return (
<motion.div
ref={errorRef}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={`p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 ${className}`}
role="alert"
aria-live="assertive"
id={id}
>
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
{error.map((err, index) => (
<span key={index} className="text-sm text-red-600 font-medium">
<span className="font-semibold">{err.field}</span>: {err.message}
</span>
))}
</div>
</motion.div>
);
}

const messages = Array.isArray(error) ? error : [error];

return (
<motion.div
Expand All @@ -37,7 +75,7 @@ export function FormError({ error, className = '', id }: FormErrorProps) {
>
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div className="flex flex-col">
{errors.map((err, index) => (
{messages.map((err, index) => (
<span key={index} className="text-sm text-red-600 font-medium">
{err}
</span>
Expand Down
1 change: 1 addition & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ class ApiClientImpl {
body?.message || response.statusText,
statusToUserMessage(response.status),
response.status,
body?.errors,
);
}

Expand Down
21 changes: 11 additions & 10 deletions src/lib/validation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { NextResponse } from 'next/server';
import { ZodTypeAny, ZodError, z } from 'zod';
import { ZodTypeAny, z } from 'zod';

// ---------------------------------------------------------------------------
// Discriminated union result type — TypeScript narrows correctly on `.ok`
// ---------------------------------------------------------------------------

export type ValidationFieldError = {
field: string;
message: string;
};

type ValidationSuccess<T> = { ok: true; data: T };
type ValidationFailure = { ok: false; error: NextResponse };
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
Expand All @@ -19,10 +24,14 @@ export function validateBody<S extends ZodTypeAny>(
): ValidationResult<z.infer<S>> {
const result = schema.safeParse(input);
if (!result.success) {
const errors: ValidationFieldError[] = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}));
return {
ok: false,
error: NextResponse.json(
{ success: false, message: formatZodError(result.error) },
{ message: 'Validation failed', errors },
{ status: 400 },
),
};
Expand All @@ -41,11 +50,3 @@ export function validateQuery<S extends ZodTypeAny>(
const raw = Object.fromEntries(searchParams.entries());
return validateBody(schema, raw);
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function formatZodError(error: ZodError): string {
return error.errors.map((e) => e.message).join('; ');
}
Loading
Loading