Skip to content
Merged
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
4 changes: 0 additions & 4 deletions frontend/form/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1 @@
VITE_FORM_SERVICE_URL=http://localhost:8002
VITE_USER_SERVICE_URL=http://localhost:8001
VITE_USER_SERVICE_TOKEN=dev-frontend-token


2 changes: 0 additions & 2 deletions frontend/form/.env.prod.example
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
VITE_FORM_SERVICE_URL=https://<your-form-service>.vercel.app
VITE_USER_SERVICE_URL=https://<your-user-service>.vercel.app
VITE_USER_SERVICE_TOKEN=<must-match-user-service-FORM_FRONTEND_TOKEN>
40 changes: 37 additions & 3 deletions frontend/form/src/components/AdminPasswordGate.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
import { type FormEvent, useState } from "react";
import { useAdminAuth } from "../hooks/useAdminAuth";
import { type FormEvent, useEffect, useState } from "react";
import { getAdminToken, useAdminAuth } from "../hooks/useAdminAuth";
import { verifyToken } from "../services/formService";

interface AdminTokenGateProps {
children: React.ReactNode;
}

const AdminTokenGate = ({ children }: AdminTokenGateProps) => {
const { isAuthorized, authorize } = useAdminAuth();
const { isAuthorized, authorize, logout } = useAdminAuth();
const [token, setToken] = useState("");
const [authError, setAuthError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);

// On mount, if a token is already stored verify it server-side.
// This prevents anyone from manually setting sessionStorage to bypass auth.
const [isVerifying, setIsVerifying] = useState(isAuthorized);

useEffect(() => {
if (!isAuthorized) {
setIsVerifying(false);
return;
}

const storedToken = getAdminToken();
if (!storedToken) {
logout();
setIsVerifying(false);
return;
}

verifyToken(storedToken).then((result) => {
if (!result.valid) {
logout();
}
setIsVerifying(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const onUnlock = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();

Expand All @@ -36,6 +62,14 @@ const AdminTokenGate = ({ children }: AdminTokenGateProps) => {
setIsLoading(false);
};

if (isVerifying) {
return (
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
</div>
);
}

if (isAuthorized) {
return <>{children}</>;
}
Expand Down
14 changes: 3 additions & 11 deletions frontend/form/src/hooks/useAdminAuth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
import { useCallback, useState } from "react";

const AUTH_KEY = "admin_authorized";
const TOKEN_KEY = "admin_api_token";

function getStoredAuth(): boolean {
if (typeof window === "undefined") {
return false;
}
return sessionStorage.getItem(AUTH_KEY) === "true";
}

/**
* Get the stored admin API token.
* This is exported for use by API services.
Expand All @@ -22,20 +14,20 @@ export function getAdminToken(): string | null {
}

export function useAdminAuth() {
const [isAuthorized, setIsAuthorized] = useState(getStoredAuth);
const [isAuthorized, setIsAuthorized] = useState(
() => sessionStorage.getItem(TOKEN_KEY) !== null,
);

const authorize = useCallback((token: string): boolean => {
if (token && token.trim().length > 0) {
sessionStorage.setItem(TOKEN_KEY, token.trim());
sessionStorage.setItem(AUTH_KEY, "true");
setIsAuthorized(true);
return true;
}
return false;
}, []);

const logout = useCallback(() => {
sessionStorage.removeItem(AUTH_KEY);
sessionStorage.removeItem(TOKEN_KEY);
setIsAuthorized(false);
}, []);
Expand Down
9 changes: 5 additions & 4 deletions frontend/form/src/hooks/useFormDetails.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { getFormById } from "../services/formService";
import type { FormResponse } from "../types";
import { ApiClientError } from "../utils/apiClientError";

interface UseFormDetailsReturn {
form: FormResponse | null;
Expand All @@ -9,10 +10,10 @@ interface UseFormDetailsReturn {
}

function formatError(error: unknown): string {
if (error instanceof Error) {
if (error.message.includes("404")) {
return "Form bulunamadı.";
}
if (error instanceof ApiClientError && error.status === 404) {
return "Form bulunamadı.";
}
if (error instanceof ApiClientError) {
return "Form yüklenirken bir hata oluştu.";
}
return "Form yüklenemedi.";
Expand Down
7 changes: 6 additions & 1 deletion frontend/form/src/hooks/useForms.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { listForms } from "../services/formService";
import type { FormPreview } from "../types";
import { ApiClientError } from "../utils/apiClientError";

interface UseFormsResult {
forms: FormPreview[];
Expand Down Expand Up @@ -36,7 +37,11 @@ export function useForms(skip = 0, limit = 20): UseFormsResult {
}
} catch (err) {
if (!isCancelled) {
setError(err instanceof Error ? err.message : "Failed to load forms");
if (err instanceof ApiClientError && err.status === 401) {
setError("Oturum doğrulanamadı. Lütfen tekrar giriş yapın.");
} else {
setError("Formlar yüklenirken bir hata oluştu.");
}
}
} finally {
if (!isCancelled) {
Expand Down
71 changes: 48 additions & 23 deletions frontend/form/src/pages/FormSubmissionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import type {
UserPayload,
UserResponse,
} from "../types";
import { ApiClientError } from "../utils/apiClientError";
import { buildFormSchema } from "../utils/buildFormSchema";
import { isQuestionVisible } from "../utils/fieldVisibility";
import { messageForPublicFormSubmitError } from "../utils/publicFormMessages";

type FormValues = Record<string, unknown>;

Expand Down Expand Up @@ -199,13 +201,6 @@ function areValuesEqual(current: unknown, target: unknown): boolean {
return current === target;
}

function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return "Beklenmeyen bir hata oluştu.";
}

function buildUserPayload(
email: string,
values: FormValues,
Expand Down Expand Up @@ -483,11 +478,14 @@ const FormSubmissionPage = () => {
try {
await createUser(payload);
} catch (createError) {
const message = formatError(createError);
if (!message.includes("409")) {
if (
createError instanceof ApiClientError &&
createError.status === 409
) {
await updateUser(normalizedEmail, payload);
} else {
throw createError;
}
await updateUser(normalizedEmail, payload);
}
}

Expand All @@ -505,7 +503,7 @@ const FormSubmissionPage = () => {
setShowSuccessAlert(true);
setSubmissionError(null);
} catch (submitError) {
setSubmissionError(formatError(submitError));
setSubmissionError(messageForPublicFormSubmitError(submitError));
}
};

Expand Down Expand Up @@ -536,6 +534,45 @@ const FormSubmissionPage = () => {
);
}

if (showSuccessAlert) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4 py-12 font-sans">
<div className="w-full max-w-2xl">
<div className="rounded-2xl shadow-lg border border-gray-200 overflow-hidden bg-white">
<Banner />
<div className="px-8 py-8">
<h1 className="text-2xl font-bold text-gray-900 font-display tracking-tight">
{form.title}
</h1>
<p className="mt-4 text-sm text-gray-700 font-medium">
Yanıtınız kaydedildi. Katılımınız için teşekkür ederiz.
</p>
<div className="mt-6">
<button
type="button"
onClick={() => {
setShowSuccessAlert(false);
setRespondentEmail("");
reset();
}}
className="text-blue-600 hover:text-blue-800 hover:underline text-sm font-medium transition"
>
Başka bir yanıt gönder
</button>
</div>
</div>
</div>
<p className="mt-8 text-center text-xs text-gray-500">
<span className="font-medium">
GDG on Campus Yaşar Üniversitesi
</span>{" "}
tarafından geliştirilmiştir.
</p>
</div>
</div>
);
}

return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4 py-12 font-sans">
<div className="w-full max-w-2xl">
Expand Down Expand Up @@ -624,18 +661,6 @@ const FormSubmissionPage = () => {
tarafından geliştirilmiştir.
</p>
</div>
{showSuccessAlert && (
<div className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm flex items-center justify-center px-4">
<div className="w-full max-w-md rounded-2xl border border-gray-200 bg-white p-6 text-center shadow-2xl">
<h2 className="text-xl font-semibold text-gray-900">
Başvurunuz Alındı
</h2>
<p className="mt-3 text-sm text-gray-600">
Formunuz başarıyla gönderildi. Katılımınız için teşekkür ederiz.
</p>
</div>
</div>
)}
</div>
);
};
Expand Down
7 changes: 6 additions & 1 deletion frontend/form/src/pages/admin/AdminFormListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import AdminPasswordGate from "../../components/AdminPasswordGate";
import { useForms } from "../../hooks/useForms";
import { deleteForm } from "../../services/formService";
import { ApiClientError } from "../../utils/apiClientError";

function formatDate(value: string | undefined): string {
if (!value) return "-";
Expand Down Expand Up @@ -51,7 +52,11 @@ const AdminFormListPage = () => {
setConfirmDeleteId(null);
refetch();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Bilinmeyen hata");
if (err instanceof ApiClientError && err.status === 401) {
setDeleteError("Oturum doğrulanamadı. Lütfen tekrar giriş yapın.");
} else {
setDeleteError("Silme işlemi başarısız. Lütfen tekrar deneyin.");
}
} finally {
setDeletingId(null);
}
Expand Down
9 changes: 5 additions & 4 deletions frontend/form/src/pages/admin/AdminFormViewsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
FormResponse,
SubmissionResponse,
} from "../../types";
import { ApiClientError } from "../../utils/apiClientError";

const EMAIL_FIELD_KEYS = ["email", "e_mail", "mail"];

Expand Down Expand Up @@ -71,10 +72,10 @@ function formatAnswer(value: unknown): string {
}

function formatError(error: unknown): string {
if (error instanceof Error) {
if (error.message.includes("404")) {
return "Form bulunamadı.";
}
if (error instanceof ApiClientError && error.status === 404) {
return "Form bulunamadı.";
}
if (error instanceof ApiClientError) {
return "Veriler yüklenirken bir hata oluştu.";
}
return "Beklenmeyen bir hata oluştu.";
Expand Down
15 changes: 13 additions & 2 deletions frontend/form/src/pages/admin/FormEditorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
FormResponse,
FormUpdate,
} from "../../types";
import { ApiClientError } from "../../utils/apiClientError";

type FormValues = Record<string, unknown>;

Expand Down Expand Up @@ -196,7 +197,11 @@ const FormEditorPage = () => {
reset(defaults);
} catch (err) {
if (isCancelled) return;
setLoadError(err instanceof Error ? err.message : "Form yüklenemedi");
if (err instanceof ApiClientError && err.status === 404) {
setLoadError("Form bulunamadı.");
} else {
setLoadError("Form yüklenirken bir hata oluştu.");
}
} finally {
if (!isCancelled) setIsLoading(false);
}
Expand Down Expand Up @@ -305,7 +310,13 @@ const FormEditorPage = () => {

navigate("/admin/forms");
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Kaydetme başarısız");
if (err instanceof ApiClientError && err.status === 401) {
setSaveError("Oturum süresi doldu veya yetkiniz yok.");
} else if (err instanceof ApiClientError) {
setSaveError("Kaydetme başarısız. Lütfen tekrar deneyin.");
} else {
setSaveError("Kaydetme başarısız.");
}
} finally {
setIsSaving(false);
}
Expand Down
18 changes: 9 additions & 9 deletions frontend/form/src/services/formService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
SubmissionCreate,
SubmissionResponse,
} from "../types";
import { throwIfNotOk } from "../utils/apiClientError";

const FORM_SERVICE_URL =
import.meta.env.VITE_FORM_SERVICE_URL ?? "http://localhost:8002";
Expand All @@ -21,14 +22,13 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
},
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Form service request failed: ${response.status} ${errorText}`,
);
}
await throwIfNotOk(response);

return (await response.json()) as T;
const text = await response.text();
if (!text.trim()) {
return undefined as T;
}
return JSON.parse(text) as T;
}

/**
Expand Down Expand Up @@ -76,7 +76,7 @@ export async function getSubmissionsByForm(
limit: String(limit),
});

return request<PaginatedSubmissionsResponse>(
return authenticatedRequest<PaginatedSubmissionsResponse>(
`/submissions/by-form/${encodeURIComponent(formId)}?${query.toString()}`,
);
}
Expand Down Expand Up @@ -115,7 +115,7 @@ export async function listForms(
active_only: String(activeOnly),
});

return request<FormListResponse>(`/forms/?${query.toString()}`);
return authenticatedRequest<FormListResponse>(`/forms/?${query.toString()}`);
}

export async function createForm(payload: FormCreate): Promise<FormResponse> {
Expand Down
Loading