diff --git a/client/eslint.config.js b/client/eslint.config.js index 68f13fa..c563228 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -9,7 +9,7 @@ import tseslint from 'typescript-eslint' import { globalIgnores } from 'eslint/config' export default tseslint.config([ - globalIgnores(['dist', 'storybook-static']), + globalIgnores(['dist', 'storybook-static', 'src/api']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/client/public/Monogram.png b/client/public/Monogram.png new file mode 100644 index 0000000..cb80547 Binary files /dev/null and b/client/public/Monogram.png differ diff --git a/client/public/auth-side.jpg b/client/public/auth-side.jpg new file mode 100644 index 0000000..18caf0f Binary files /dev/null and b/client/public/auth-side.jpg differ diff --git a/client/public/sarah.jpg b/client/public/sarah.jpg new file mode 100644 index 0000000..2586f98 Binary files /dev/null and b/client/public/sarah.jpg differ diff --git a/client/src/App.tsx b/client/src/App.tsx index 67b9704..c07dd43 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -7,14 +7,15 @@ import BackgroundImage from "./components/common/BackgroundImage"; import { CreateIncome } from "./pages/CreateIncome"; import { EditIncome } from "./pages/EditIncome"; import Mascot from "./components/common/Mascot"; -import { DashboardHeader } from "./components/common/Header"; +import DashboardHeader from "./components/common/Header"; +import RequireAuth from "./components/common/RequireAuth"; +import LoginPage from "./pages/LoginPage"; +import SignupPage from "./pages/SignupPage"; +import AuthCallback from "./pages/AuthCallback"; +import DashboardLayout from "./components/common/DashboardLayout"; import { Profile } from "./pages/Profile"; function App() { - /* - TODO: check auth then redirect to /login when not authenticated - TODO: use different layout for auth page and dashboard page - */ const location = useLocation(); return (
{location.pathname.includes("/login") || - location.pathname.includes("/register") ? null : ( + location.pathname.includes("/signup") ? null : ( <> )} - + {location.pathname.includes("/login") || location.pathname.includes("/signup") ? null : ( + + )} - } /> - } /> - } /> - } /> - } /> + {/* Public routes */} + } /> + } /> + } /> + + {/* Protected routes */} + }> + }> + } /> + } /> + } /> + } /> + } /> + +
diff --git a/client/src/api/core/ApiError.ts b/client/src/api/core/ApiError.ts index df9f43e..ec7b16a 100644 --- a/client/src/api/core/ApiError.ts +++ b/client/src/api/core/ApiError.ts @@ -22,4 +22,4 @@ export class ApiError extends Error { this.body = response.body; this.request = request; } -} +} diff --git a/client/src/api/core/ApiRequestOptions.ts b/client/src/api/core/ApiRequestOptions.ts index a93ee2a..93143c3 100644 --- a/client/src/api/core/ApiRequestOptions.ts +++ b/client/src/api/core/ApiRequestOptions.ts @@ -14,4 +14,4 @@ export type ApiRequestOptions = { readonly mediaType?: string; readonly responseHeader?: string; readonly errors?: Record; -}; +}; diff --git a/client/src/api/core/ApiResult.ts b/client/src/api/core/ApiResult.ts index e23d6c8..ee1126e 100644 --- a/client/src/api/core/ApiResult.ts +++ b/client/src/api/core/ApiResult.ts @@ -8,4 +8,4 @@ export type ApiResult = { readonly status: number; readonly statusText: string; readonly body: any; -}; +}; diff --git a/client/src/api/core/CancelablePromise.ts b/client/src/api/core/CancelablePromise.ts index 9ee9569..d70de92 100644 --- a/client/src/api/core/CancelablePromise.ts +++ b/client/src/api/core/CancelablePromise.ts @@ -128,4 +128,4 @@ export class CancelablePromise implements Promise { public get isCancelled(): boolean { return this.#isCancelled; } -} +} diff --git a/client/src/api/core/request.ts b/client/src/api/core/request.ts index 6cf8280..1dc6fef 100644 --- a/client/src/api/core/request.ts +++ b/client/src/api/core/request.ts @@ -2,315 +2,285 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import axios from "axios"; -import type { - AxiosError, - AxiosRequestConfig, - AxiosResponse, - AxiosInstance, -} from "axios"; -import FormData from "form-data"; - -import { ApiError } from "./ApiError"; -import type { ApiRequestOptions } from "./ApiRequestOptions"; -import type { ApiResult } from "./ApiResult"; -import { CancelablePromise } from "./CancelablePromise"; -import type { OnCancel } from "./CancelablePromise"; -import type { OpenAPIConfig } from "./OpenAPI"; - -export const isDefined = ( - value: T | null | undefined -): value is Exclude => { - return value !== undefined && value !== null; +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = (value: T | null | undefined): value is Exclude => { + return value !== undefined && value !== null; }; export const isString = (value: any): value is string => { - return typeof value === "string"; + return typeof value === 'string'; }; export const isStringWithValue = (value: any): value is string => { - return isString(value) && value !== ""; + return isString(value) && value !== ''; }; export const isBlob = (value: any): value is Blob => { - return ( - typeof value === "object" && - typeof value.type === "string" && - typeof value.stream === "function" && - typeof value.arrayBuffer === "function" && - typeof value.constructor === "function" && - typeof value.constructor.name === "string" && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); }; export const isFormData = (value: any): value is FormData => { - return value instanceof FormData; + return value instanceof FormData; }; export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300; + return status >= 200 && status < 300; }; export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString("base64"); - } + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } }; export const getQueryString = (params: Record): string => { - const qs: string[] = []; + const qs: string[] = []; - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; - const process = (key: string, value: any) => { - if (isDefined(value)) { - if (Array.isArray(value)) { - value.forEach((v) => { - process(key, v); - }); - } else if (typeof value === "object") { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); - } - } - }; + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); - if (qs.length > 0) { - return `?${qs.join("&")}`; - } + if (qs.length > 0) { + return `?${qs.join('&')}`; + } - return ""; + return ''; }; const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace("{api-version}", config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); - const url = `${config.BASE}${path}`; - if (options.query) { - return `${url}${getQueryString(options.query)}`; - } - return url; + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; }; -export const getFormData = ( - options: ApiRequestOptions -): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; - - Object.entries(options.formData) - .filter(([_, value]) => isDefined(value)) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach((v) => process(key, v)); - } else { - process(key, value); - } - }); + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; - return formData; - } - return undefined; + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; }; type Resolver = (options: ApiRequestOptions) => Promise; -export const resolve = async ( - options: ApiRequestOptions, - resolver?: T | Resolver -): Promise => { - if (typeof resolver === "function") { - return (resolver as Resolver)(options); - } - return resolver; +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; }; -export const getHeaders = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, - formData?: FormData -): Promise> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), - ]); - - const formHeaders = - (typeof formData?.getHeaders === "function" && formData?.getHeaders()) || - {}; - - const headers = Object.entries({ - Accept: "application/json", - ...additionalHeaders, - ...options.headers, - ...formHeaders, - }) +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) .filter(([_, value]) => isDefined(value)) - .reduce( - (headers, [key, value]) => ({ + .reduce((headers, [key, value]) => ({ ...headers, [key]: String(value), - }), - {} as Record - ); + }), {} as Record); - if (isStringWithValue(token)) { - headers["Authorization"] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers["Authorization"] = `Basic ${credentials}`; - } - - if (options.body !== undefined) { - if (options.mediaType) { - headers["Content-Type"] = options.mediaType; - } else if (isBlob(options.body)) { - headers["Content-Type"] = options.body.type || "application/octet-stream"; - } else if (isString(options.body)) { - headers["Content-Type"] = "text/plain"; - } else if (!isFormData(options.body)) { - headers["Content-Type"] = "application/json"; + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; } - } - return headers; + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; }; export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body) { - return options.body; - } - return undefined; + if (options.body) { + return options.body; + } + return undefined; }; export const sendRequest = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Record, - onCancel: OnCancel, - axiosClient: AxiosInstance + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance ): Promise> => { - const source = axios.CancelToken.source(); - - const requestConfig: AxiosRequestConfig = { - url, - headers, - data: body ?? formData, - method: options.method, - withCredentials: config.WITH_CREDENTIALS, - withXSRFToken: - config.CREDENTIALS === "include" ? config.WITH_CREDENTIALS : false, - cancelToken: source.token, - }; - - onCancel(() => source.cancel("The user aborted a request.")); - - try { - return await axiosClient.request(requestConfig); - } catch (error) { - const axiosError = error as AxiosError; - if (axiosError.response) { - return axiosError.response; + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; + } + throw error; } - throw error; - } }; -export const getResponseHeader = ( - response: AxiosResponse, - responseHeader?: string -): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; +export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } } - } - return undefined; + return undefined; }; export const getResponseBody = (response: AxiosResponse): any => { - if (response.status !== 204) { - return response.data; - } - return undefined; + if (response.status !== 204) { + return response.data; + } + return undefined; }; -export const catchErrorCodes = ( - options: ApiRequestOptions, - result: ApiResult -): void => { - const errors: Record = { - 400: "Bad Request", - 401: "Unauthorized", - 403: "Forbidden", - 404: "Not Found", - 500: "Internal Server Error", - 502: "Bad Gateway", - 503: "Service Unavailable", - ...options.errors, - }; - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? "unknown"; - const errorStatusText = result.statusText ?? "unknown"; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError( - options, - result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` - ); - } +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } }; /** @@ -321,49 +291,33 @@ export const catchErrorCodes = ( * @returns CancelablePromise * @throws ApiError */ -export const request = ( - config: OpenAPIConfig, - options: ApiRequestOptions, - axiosClient: AxiosInstance = axios -): CancelablePromise => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options, formData); - - const axiosOptions = { - url, - method: options.method, - data: body, - headers, - params: url.includes("?") ? undefined : options.query, - withCredentials: true, - }; - - if (!onCancel.isCancelled) { - const response = await axiosClient.request(axiosOptions); - const responseBody = getResponseBody(response); - const responseHeader = getResponseHeader( - response, - options.responseHeader - ); - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); +export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); }; diff --git a/client/src/api/index.ts b/client/src/api/index.ts index 4e78275..d0237d7 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -7,4 +7,6 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; +export type { PublicUser } from './models/PublicUser'; + export { DefaultService } from './services/DefaultService'; diff --git a/client/src/api/models/PublicUser.ts b/client/src/api/models/PublicUser.ts new file mode 100644 index 0000000..eadc7fb --- /dev/null +++ b/client/src/api/models/PublicUser.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type PublicUser = { + user_id: number; + email: string; + username?: string | null; + firstname?: string | null; + lastname?: string | null; + created_at: string; +}; + diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts index de18745..c303500 100644 --- a/client/src/api/services/DefaultService.ts +++ b/client/src/api/services/DefaultService.ts @@ -1,418 +1,476 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { CancelablePromise } from "../core/CancelablePromise"; -import { OpenAPI } from "../core/OpenAPI"; -import { request as __request } from "../core/request"; -export class DefaultService { - /** - * Register a new user - * @param requestBody - * @returns any User created - * @throws ApiError - */ - public static postAuthSignup(requestBody: { - email: string; - password: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: "POST", - url: "/auth/signup", - body: requestBody, - mediaType: "application/json", - }); - } - /** - * Login and receive JWT token - * @param requestBody - * @returns any Token returned - * @throws ApiError - */ - public static postAuthLogin(requestBody: { - email: string; - password: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: "POST", - url: "/auth/login", - body: requestBody, - mediaType: "application/json", - }); - } - /** - * List all user expenses - * @param start Start date (ISO string) - * @param end End date (ISO string) - * @param category - * @param type - * @returns any List of expenses - * @throws ApiError - */ - public static getExpenses( - start?: string, - end?: string, - category?: string, - type?: "recurring" | "one-time" - ): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/expenses", - query: { - start: start, - end: end, - category: category, - type: type, - }, - }); - } - /** - * Create a new expense - * @param formData - * @returns any Expense created - * @throws ApiError - */ - public static postExpenses(formData?: { - amount: number; - date: string; - categoryId: string; - description?: string; - type?: "one-time" | "recurring"; - startDate?: string; - endDate?: string; - receipt?: Blob; - }): CancelablePromise { - return __request(OpenAPI, { - method: "POST", - url: "/expenses", - formData: formData, - mediaType: "multipart/form-data", - }); - } - /** - * Get a single expense - * @param id - * @returns any Expense data - * @throws ApiError - */ - public static getExpenses1(id: string): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/expenses/{id}", - path: { - id: id, - }, - }); - } - /** - * Update an expense - * @param id - * @param formData - * @returns any Expense updated - * @throws ApiError - */ - public static putExpenses( - id: string, - formData?: { - amount?: number; - date?: string; - categoryId?: string; - description?: string; - type?: "one-time" | "recurring"; - startDate?: string; - endDate?: string; - receipt?: Blob; - } - ): CancelablePromise { - return __request(OpenAPI, { - method: "PUT", - url: "/expenses/{id}", - path: { - id: id, - }, - formData: formData, - mediaType: "multipart/form-data", - }); - } - /** - * Delete an expense - * @param id - * @param id - * @returns void - * @throws ApiError - */ - public static deleteExpenses(id: string): CancelablePromise { - return __request(OpenAPI, { - method: "DELETE", - url: "/expenses/{id}", - path: { - id: id, - }, - }); - } - /** - * List all incomes - * @param start - * @param end - * @returns any List of incomes - * @throws ApiError - */ - public static getIncomes( - start?: string, - end?: string - ): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/incomes", - query: { - start: start, - end: end, - }, - }); - } - /** - * Create new income - * @param requestBody - * @returns any Income created - * @throws ApiError - */ - public static postIncomes(requestBody?: { - amount: number; - date: string; - source?: string; - description?: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: "POST", - url: "/incomes", - body: requestBody, - mediaType: "application/json", - }); - } - /** - * Get an income entry - * @param id - * @returns any Income data - * @throws ApiError - */ - public static getIncomes1(id: string): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/incomes/{id}", - path: { - id: id, - }, - }); - } - /** - * Update an income entry - * @param id - * @param requestBody - * @returns any Income updated - * @throws ApiError - */ - public static putIncomes( - id: string, - requestBody?: { - amount?: number; - date?: string; - source?: string; - description?: string; - } - ): CancelablePromise { - return __request(OpenAPI, { - method: "PUT", - url: "/incomes/{id}", - path: { - id: id, - }, - body: requestBody, - mediaType: "application/json", - }); - } - /** - * Delete an income entry - * @param id - * @param id - * @returns void - * @throws ApiError - */ - public static deleteIncomes(id: string): CancelablePromise { - return __request(OpenAPI, { - method: "DELETE", - url: "/incomes/{id}", - path: { - id: id, - }, - }); - } - /** - * List user categories - * @returns any List of categories - * @throws ApiError - */ - public static getCategories(): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/categories", - }); - } - /** - * Create new category - * @param requestBody - * @returns any Category created - * @throws ApiError - */ - public static postCategories(requestBody?: { - name: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: "POST", - url: "/categories", - body: requestBody, - mediaType: "application/json", - }); - } - /** - * Rename a category - * @param id - * @param requestBody - * @returns any Category updated - * @throws ApiError - */ - public static putCategories( - id: string, - requestBody?: { - name?: string; - } - ): CancelablePromise { - return __request(OpenAPI, { - method: "PUT", - url: "/categories/{id}", - path: { - id: id, - }, - body: requestBody, - mediaType: "application/json", - }); - } - /** - * Delete a category - * @param id - * @returns void - * @throws ApiError - */ - public static deleteCategories(id: string): CancelablePromise { - return __request(OpenAPI, { - method: "DELETE", - url: "/categories/{id}", - path: { - id: id, - }, - }); - } - /** - * Get current month summary - * @param month - * @returns any Monthly summary - * @throws ApiError - */ - public static getSummaryMonthly(month?: string): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/summary/monthly", - query: { - month: month, - }, - }); - } - /** - * Get summary for custom range - * @param start - * @param end - * @returns any Summary - * @throws ApiError - */ - public static getSummary( - start?: string, - end?: string - ): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/summary", - query: { - start: start, - end: end, - }, - }); - } - /** - * Budget overrun alert - * @returns any Alert info - * @throws ApiError - */ - public static getSummaryAlerts(): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/summary/alerts", - }); - } - /** - * Download/view receipt - * @param id - * @returns any Receipt file - * @throws ApiError - */ - public static getReceipts(id: string): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/receipts/{id}", - path: { - id: id, - }, - }); - } - /** - * Get user profile - * @returns any Profile info - * @throws ApiError - */ - public static getUserProfile(): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/user/profile", - }); - } - /** - * Update user profile - * @param requestBody - * @returns any Profile updated - * @throws ApiError - */ - public static putUserProfile( - requestBody: Record - ): CancelablePromise { - return __request(OpenAPI, { - method: "PUT", - url: "/user/profile", - body: requestBody, - mediaType: "application/json", - }); - } - /** - * Update user password - * @param requestBody - * @returns any Password updated - * @throws ApiError - */ - public static patchUserProfilePassword( - requestBody: Record - ): CancelablePromise { - return __request(OpenAPI, { - method: "PATCH", - url: "/user/profile/password", - body: requestBody, - mediaType: "application/json", - }); - } -} +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { PublicUser } from '../models/PublicUser'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class DefaultService { + /** + * Register a new user + * @param requestBody + * @returns any User created + * @throws ApiError + */ + public static postAuthSignup( + requestBody: { + email: string; + password: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/signup', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Login and receive JWT token + * @param requestBody + * @returns any Token returned + * @throws ApiError + */ + public static postAuthLogin( + requestBody: { + email: string; + password: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/login', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * List all user expenses + * @param start Start date (ISO string) + * @param end End date (ISO string) + * @param category + * @param type + * @returns any List of expenses + * @throws ApiError + */ + public static getExpenses( + start?: string, + end?: string, + category?: string, + type?: 'recurring' | 'one-time', + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/expenses', + query: { + 'start': start, + 'end': end, + 'category': category, + 'type': type, + }, + }); + } + /** + * Create a new expense + * @param formData + * @returns any Expense created + * @throws ApiError + */ + public static postExpenses( + formData?: { + amount: number; + date: string; + categoryId: string; + description?: string; + type?: 'one-time' | 'recurring'; + startDate?: string; + endDate?: string; + receipt?: Blob; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/expenses', + formData: formData, + mediaType: 'multipart/form-data', + }); + } + /** + * Get a single expense + * @param id + * @returns any Expense data + * @throws ApiError + */ + public static getExpenses1( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/expenses/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Update an expense + * @param id + * @param formData + * @returns any Expense updated + * @throws ApiError + */ + public static putExpenses( + id: string, + formData?: { + amount?: number; + date?: string; + categoryId?: string; + description?: string; + type?: 'one-time' | 'recurring'; + startDate?: string; + endDate?: string; + receipt?: Blob; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/expenses/{id}', + path: { + 'id': id, + }, + formData: formData, + mediaType: 'multipart/form-data', + }); + } + /** + * Delete an expense + * @param id + * @returns void + * @throws ApiError + */ + public static deleteExpenses( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/expenses/{id}', + path: { + 'id': id, + }, + }); + } + /** + * List all incomes + * @param start + * @param end + * @returns any List of incomes + * @throws ApiError + */ + public static getIncomes( + start?: string, + end?: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/incomes', + query: { + 'start': start, + 'end': end, + }, + }); + } + /** + * Create new income + * @param requestBody + * @returns any Income created + * @throws ApiError + */ + public static postIncomes( + requestBody?: { + amount: number; + date: string; + source?: string; + description?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/incomes', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Get an income entry + * @param id + * @returns any Income data + * @throws ApiError + */ + public static getIncomes1( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/incomes/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Update an income entry + * @param id + * @param requestBody + * @returns any Income updated + * @throws ApiError + */ + public static putIncomes( + id: string, + requestBody?: { + amount?: number; + date?: string; + source?: string; + description?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/incomes/{id}', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Delete an income entry + * @param id + * @returns void + * @throws ApiError + */ + public static deleteIncomes( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/incomes/{id}', + path: { + 'id': id, + }, + }); + } + /** + * List user categories + * @returns any List of categories + * @throws ApiError + */ + public static getCategories(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/categories', + }); + } + /** + * Create new category + * @param requestBody + * @returns any Category created + * @throws ApiError + */ + public static postCategories( + requestBody?: { + name: string; + icon_url?: string | null; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/categories', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Rename a category + * @param id + * @param requestBody + * @returns any Category updated + * @throws ApiError + */ + public static putCategories( + id: string, + requestBody?: { + name?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/categories/{id}', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Delete a category + * @param id + * @returns void + * @throws ApiError + */ + public static deleteCategories( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/categories/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Get current month summary + * @param month + * @returns any Monthly summary + * @throws ApiError + */ + public static getSummaryMonthly( + month?: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/summary/monthly', + query: { + 'month': month, + }, + }); + } + /** + * Get summary for custom range + * @param start + * @param end + * @returns any Summary + * @throws ApiError + */ + public static getSummary( + start?: string, + end?: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/summary', + query: { + 'start': start, + 'end': end, + }, + }); + } + /** + * Budget overrun alert + * @returns any Alert info + * @throws ApiError + */ + public static getSummaryAlerts(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/summary/alerts', + }); + } + /** + * Download/view receipt + * @param id + * @returns any Receipt file + * @throws ApiError + */ + public static getReceipts( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/receipts/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Get user profile + * @returns any Profile info + * @throws ApiError + */ + public static getUserProfile(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/user/profile', + }); + } + /** + * Update user profile + * Update username for the authenticated user + * @param requestBody + * @returns PublicUser Public user + * @throws ApiError + */ + public static patchUserProfile( + requestBody: { + username?: string; + firstname?: string; + lastname?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/user/profile', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid username`, + 401: `Unauthorized`, + }, + }); + } + /** + * Update user password + * @param requestBody + * @returns any Password updated + * @throws ApiError + */ + public static patchUserProfilePassword( + requestBody: Record, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/user/profile/password', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Get current authenticated user + * @returns PublicUser Public user + * @throws ApiError + */ + public static getAuthMe(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/auth/me', + errors: { + 401: `Unauthorized`, + }, + }); + } + /** + * Logout current session + * @returns void + * @throws ApiError + */ + public static postAuthLogout(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/logout', + }); + } +} diff --git a/client/src/components/auth/AuthLayout.tsx b/client/src/components/auth/AuthLayout.tsx new file mode 100644 index 0000000..285024e --- /dev/null +++ b/client/src/components/auth/AuthLayout.tsx @@ -0,0 +1,39 @@ + +import FeatureShowcase from "./FeatureShowcase"; + +type Props = { + children: React.ReactNode; + title?: string; + subtitle?: React.ReactNode; + sideImageUrl?: string; +}; + +export default function AuthLayout({ children, title, subtitle, sideImageUrl = "/auth-side.jpg" }: Props) { + return ( +
+
+ Logo + PennyPal +
+ +
+
+
+
+ {title ? ( +

{title}

+ ) : null} + {subtitle ?
{subtitle}
: null} + +
+ {children} +
+
+
+ + +
+
+
+ ); +} diff --git a/client/src/components/auth/CategoriesOnboardingModal.tsx b/client/src/components/auth/CategoriesOnboardingModal.tsx new file mode 100644 index 0000000..d44e748 --- /dev/null +++ b/client/src/components/auth/CategoriesOnboardingModal.tsx @@ -0,0 +1,124 @@ +import { useEffect, useMemo, useState } from "react"; +import Dialog from "../../ui/Dialog"; +import { Button } from "../../ui"; +import { DefaultService } from "../../api"; +import { useToast } from "../../ui"; +import type { Category } from "../../types/Auth"; +import { modalCls, submitCls } from "./constants"; +import { CheckCircle2, Circle } from "lucide-react"; + +type Props = { + open: boolean; + onClose: () => void; + onDone: () => void; +}; + +export default function CategoriesOnboardingModal({ open, onClose, onDone }: Props) { + const toast = useToast(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [allCategories, setAllCategories] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + useEffect(() => { + if (!open) return; + let mounted = true; + (async () => { + setLoading(true); + try { + const cats = (await DefaultService.getCategories()) as Category[]; + if (!mounted) return; + setAllCategories(cats || []); + const globals = (cats || []).filter(c => c.user_id == null); + setSelectedIds(new Set(globals.map(c => c.category_id))); + } catch { + if (!mounted) return; + toast.error("Failed to load categories"); + } finally { + if (mounted) setLoading(false); + } + })(); + return () => { mounted = false; }; + }, [open, toast]); + + const defaultCategories = useMemo(() => allCategories.filter(c => c.user_id == null), [allCategories]); + + const toggle = (id: number) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const submit = async () => { + setSaving(true); + try { + const toUse = selectedIds.size > 0 + ? defaultCategories.filter(c => selectedIds.has(c.category_id)) + : defaultCategories; + for (const cat of toUse) { + try { + await DefaultService.postCategories({ name: cat.category_name, icon_url: cat.icon_url ?? undefined }); + } catch { + /* ignore */ + } + } + toast.success("Categories ready"); + onDone(); + } catch { + toast.error("Failed to create categories"); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + } + classes={modalCls} + + > +
+

Select the default categories you want to start with. If you don't choose any, we'll add all defaults.

+
+ {loading ? ( +
Loading...
+ ) : defaultCategories.length === 0 ? ( +
No default categories available.
+ ) : ( + defaultCategories.map(cat => ( + + )) + )} +
+
+
+ ); +} diff --git a/client/src/components/auth/FeatureShowcase.tsx b/client/src/components/auth/FeatureShowcase.tsx new file mode 100644 index 0000000..6e4f925 --- /dev/null +++ b/client/src/components/auth/FeatureShowcase.tsx @@ -0,0 +1,85 @@ +import { StarFilledIcon } from "@radix-ui/react-icons"; +import { features, landingContent } from "./constants"; + +type Props = { + sideImageUrl?: string; +} + +export default function FeatureShowcase({ sideImageUrl }: Props) { + return ( +
+
+ Auth side + +
+
+
+

+ {landingContent.hero.title} {landingContent.hero.titleHighlight} +

+

+ {landingContent.hero.subtitle} +

+
+ +
+
+ +
+
+ "{landingContent.testimonial.quote}" +
+
+
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ {landingContent.testimonial.author} +
+
+ + {/* Feature highlights - Auto-sliding carousel */} +
+
+ {features.map((feature) => { + const IconComponent = feature.icon; + return ( +
+
+ +
+
+

{feature.title}

+

{feature.description}

+
+
+ ); + })} + + {features.map((feature) => { + const IconComponent = feature.icon; + return ( +
+
+ +
+
+

{feature.title}

+

{feature.description}

+
+
+ ); + })} +
+
+
+
+
+
+ ) +}; \ No newline at end of file diff --git a/client/src/components/auth/GoogleButton.tsx b/client/src/components/auth/GoogleButton.tsx new file mode 100644 index 0000000..00daa29 --- /dev/null +++ b/client/src/components/auth/GoogleButton.tsx @@ -0,0 +1,47 @@ +import { Button } from "../../ui"; + +type GoogleButtonProps = { + label?: string; + redirectUrl?: string; // defaults to /api/auth/google + fullWidth?: boolean; + className?: string; +}; + +export default function GoogleButton({ + label = "Continue with Google", + redirectUrl = "/api/auth/google", + fullWidth = true, + className, +}: GoogleButtonProps) { + const GoogleIcon = ( + + + + + + + ); + return ( + + ); +} + +export const OrSeparation = () => { + return ( +
+
+ or +
+
+ ) +} diff --git a/client/src/components/auth/LoginForm.tsx b/client/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..b52a7d3 --- /dev/null +++ b/client/src/components/auth/LoginForm.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { Button, TextField } from "../../ui"; +import { Link } from "react-router-dom"; +import { type LoginFormProps } from "../../types/Auth"; +import { authLabelCls, authInputCls, submitCls, authGoogleCls } from "./constants"; +import GoogleButton, { OrSeparation } from "./GoogleButton"; +import ShowPasswordBtn from "./ShowPasswordBtn"; + +export default function LoginForm({ + onSubmit, + loading = false, + error = null, + submitLabel = "Sign in", + showSignupLink = true, +}: LoginFormProps) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [roEmail, setRoEmail] = useState(true); + const [roPassword, setRoPassword] = useState(true); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + await onSubmit({ email, password }); + } + + + return ( +
+ {error && ( +
+ {error} +
+ )} + + + setEmail(e.target.value)} + variant="standard" + required + autoComplete="email" + inputMode="email" + readOnly={roEmail} + onFocus={() => setRoEmail(false)} + classes={{ label: authLabelCls, input: authInputCls }} + /> + setPassword(e.target.value)} + variant="standard" + required + readOnly={roPassword} + onFocus={() => setRoPassword(false)} + classes={{ label: authLabelCls, input: authInputCls }} + endAdornment={ + setShowPassword((p) => !p)} showPassword={showPassword}/> + } + /> + + {showSignupLink && ( +
+ Don't have an account?{" "} + + Sign up for free + +
+ )} + + ); +} diff --git a/client/src/components/auth/PasswordStrength.tsx b/client/src/components/auth/PasswordStrength.tsx new file mode 100644 index 0000000..cfe1fad --- /dev/null +++ b/client/src/components/auth/PasswordStrength.tsx @@ -0,0 +1,51 @@ + +type Props = { password: string }; + +function scorePassword(pw: string) { + let score = 0; + if (!pw) return 0; + if (pw.length >= 8) score++; + if (/[a-z]/.test(pw)) score++; + if (/[A-Z]/.test(pw)) score++; + if (/[0-9]/.test(pw)) score++; + if (/[^A-Za-z0-9]/.test(pw)) score++; + return Math.min(score, 5); // 0..5 +} + +const labelAndColor = (score: number): { label: string; color: string; bar: string } => { + switch (score) { + case 0: + case 1: + return { label: 'Very weak', color: 'text-red-600', bar: 'bg-red-400' }; + case 2: + return { label: 'Weak', color: 'text-orange-600', bar: 'bg-orange-400' }; + case 3: + return { label: 'Fair', color: 'text-yellow-600', bar: 'bg-yellow-400' }; + case 4: + return { label: 'Good', color: 'text-green-600', bar: 'bg-green-400' }; + case 5: + default: + return { label: 'Strong', color: 'text-emerald-600', bar: 'bg-emerald-500' }; + } +} + +export default function PasswordStrength({ password }: Props) { + const score = scorePassword(password); + const { label, color, bar } = labelAndColor(score); + const segments = 5; + + return ( +
+
+ {Array.from({ length: segments }).map((_, i) => ( + + ))} +
+
{label}
+
+ ); +} diff --git a/client/src/components/auth/ShowPasswordBtn.tsx b/client/src/components/auth/ShowPasswordBtn.tsx new file mode 100644 index 0000000..d6b8228 --- /dev/null +++ b/client/src/components/auth/ShowPasswordBtn.tsx @@ -0,0 +1,27 @@ + +import { Eye, EyeOff } from "lucide-react"; +import { IconButton } from "../../ui"; + +type ShowPasswordBtnProps = { + onClick() : void, + showPassword: boolean, +} + +export default function ShowPasswordBtn({onClick, showPassword} : ShowPasswordBtnProps) { + return ( + + {showPassword ? ( + + ) : ( + + )} + + ) +} \ No newline at end of file diff --git a/client/src/components/auth/SignupForm.tsx b/client/src/components/auth/SignupForm.tsx new file mode 100644 index 0000000..ff0b87d --- /dev/null +++ b/client/src/components/auth/SignupForm.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from "react"; +import { Button, TextField } from "../../ui"; +import { Link, useNavigate } from "react-router-dom"; +import { type SignupFormProps } from "../../types/Auth"; +import { authLabelCls, authInputCls, authGoogleCls, submitCls } from "./constants"; +import GoogleButton, { OrSeparation } from "./GoogleButton"; +import ShowPasswordBtn from "./ShowPasswordBtn"; +import PasswordStrength from "./PasswordStrength"; +import { useToast } from "../../ui"; +import UsernameModal from "./UsernameModal"; +import { useAuth } from "../../hooks/useAuth"; +import CategoriesOnboardingModal from "./CategoriesOnboardingModal"; + +export default function SignupForm({ + onSubmit, + loading = false, + error = null, + submitLabel = "Sign up", + showLoginLink = true, +}: SignupFormProps) { + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [emailError, setEmailError] = useState(null); + const [confirmPasswordError, setConfirmPasswordError] = useState(null); + const [postSignupOpen, setPostSignupOpen] = useState(false); + const [postCategoriesOpen, setPostCategoriesOpen] = useState(false); + const [usernameModal, setUsernameModal] = useState(""); + const [savingUsername, setSavingUsername] = useState(false); + const toast = useToast() + const { updateProfile } = useAuth(); + + const isValidEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmedEmail = email.trim(); + if (!isValidEmail(trimmedEmail)) { + setEmailError("Please enter a valid email"); + return; + } + if (!password) return; + if (confirmPassword !== password) { + setConfirmPasswordError("Passwords do not match"); + return; + } + const payload = { + email: trimmedEmail, + password, + }; + try { + await onSubmit({ ...payload, confirmPassword }); + setUsernameModal(""); + setPostSignupOpen(true); + } catch (err) { + const msg = err instanceof Error ? err.message : "Sign up failed"; + toast.error(msg); + } + } + + useEffect(() => { + if (error) toast.error(error); + }, [error, toast]); + + return ( +
+ {error && ( +
+ {error} +
+ )} + + + { + const v = e.target.value; + setEmail(v); + if (!v) { + setEmailError(null); + } else if (!isValidEmail(v.trim())) { + setEmailError("Invalid email format"); + } else { + setEmailError(null); + } + }} + variant="standard" + required + error={!!emailError} + helperText={emailError || undefined} + classes={{ label: authLabelCls, input: authInputCls, error: 'hidden' }} + autoComplete="email" + autoCorrect="off" + autoCapitalize="none" + /> + setPassword(e.target.value)} + variant="standard" + required + classes={{ label: authLabelCls, input: authInputCls }} + autoComplete="new-password" + endAdornment={ + setShowPassword((p) => !p)} showPassword={showPassword}/> + } + /> + { + const v = e.target.value; + setConfirmPassword(v); + setConfirmPasswordError(v && v !== password ? "Passwords do not match" : null); + }} + variant="standard" + required + error={!!confirmPasswordError} + helperText={confirmPasswordError || undefined} + classes={{ label: authLabelCls, input: authInputCls, error: 'hidden' }} + autoComplete="new-password" + endAdornment={ + setShowConfirmPassword((p) => !p)} showPassword={showConfirmPassword}/> + } + /> + + + {showLoginLink && ( +
+ Already have an account?{" "} + + Sign in + +
+ )} + + setPostSignupOpen(false)} + onSkip={() => { setPostSignupOpen(false); setPostCategoriesOpen(true); }} + onSave={async () => { + setSavingUsername(true); + try { + const uname = usernameModal.trim(); + if (uname) { + await updateProfile({ username: uname }); + } + toast.success("Saved"); + setPostSignupOpen(false); + setPostCategoriesOpen(true); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to save username'; + toast.error(msg); + } finally { + setSavingUsername(false); + } + }} + /> + { setPostCategoriesOpen(false); setPostSignupOpen(true); }} + onDone={() => { setPostCategoriesOpen(false); navigate('/', { replace: true }); }} + /> + + ); +} diff --git a/client/src/components/auth/UsernameModal.tsx b/client/src/components/auth/UsernameModal.tsx new file mode 100644 index 0000000..bd9a94a --- /dev/null +++ b/client/src/components/auth/UsernameModal.tsx @@ -0,0 +1,72 @@ +import { Button, TextField } from "../../ui"; +import Dialog from "../../ui/Dialog"; +import { useMemo } from "react"; +import type { UsernameModalProps } from "../../types/Auth"; +import { modalInputCls, submitCls, textFieldModalCls } from "./constants"; + +const USERNAME_REGEX = /^[A-Za-z0-9_-]{3,50}$/; + +export default function UsernameModal({ + open, + email, + username, + onUsernameChange, + onClose, + onSkip, + onSave, + saving = false, +}: UsernameModalProps) { + const usernameError = useMemo(() => { + const v = username.trim(); + if (!v) return null; + if (!USERNAME_REGEX.test(v)) return "3–50 chars, letters, numbers, _ or -"; + return null; + }, [username]); + const usernameHelper = useMemo(() => { + const v = username.trim(); + if (!v) return "Optional"; + return usernameError ?? undefined; + }, [username, usernameError]); + + return ( + + + + + } + classes={{panel: "!bg-primary/80 !backdrop-blur-sm", title: "!text-light", header:"!text-primary-dark", overlay:"!bg-black/10",body: "text-light" }} + > +
+

You’re signed up. Choose a username to personalize your account.

+ {}} + disabled + variant="standard" + autoComplete="off" + classes={{ label: `!text-accent`, input: `${modalInputCls} text-white`}} + /> + onUsernameChange(e.target.value.slice(0, 50))} + variant="standard" + error={!!usernameError} + helperText={usernameHelper} + classes={textFieldModalCls} + autoComplete="off" + /> +
+
+ ); +} diff --git a/client/src/components/auth/constants.ts b/client/src/components/auth/constants.ts new file mode 100644 index 0000000..483e63d --- /dev/null +++ b/client/src/components/auth/constants.ts @@ -0,0 +1,73 @@ + +import { DollarSign, TrendingUp, Target } from "lucide-react"; + +export const authLabelCls = 'peer-focus:!text-primary peer-placeholder-shown:!text-primary'; + +export const modalLabelCls = 'peer-focus:!text-accent peer-placeholder-shown:!text-white' + +export const authInputCls = '!border-b-1 !border-gray-400 focus:!border-primary'; + +export const modalInputCls = '!border-b-1 !border-gray-400 focus:!border-accent !text-white' + +export const submitCls = `bg-primary text-white hover:bg-primary/90 focus:ring-2 focus:ring-accent focus:bg-primary/90` + +export const authGoogleCls = `!border !border-primary-light !bg-white hover:!bg-accent/10 focus:!border-accent focus:bg-accent/10` + +export const textFieldModalCls = { + label: `${modalLabelCls}`, + input: `${modalInputCls}`, + helperText:"!text-light", + error: "hidden" +}; + +export const modalCls = { + panel: "!bg-primary/80 !backdrop-blur-sm", + title: "!text-light", + overlay:"!bg-black/10", + body: "text-light" +} + +export const landingContent = { + hero: { + title: "Take control of your", + titleHighlight: "finances", + subtitle: "Track expenses, manage income, and achieve your financial goals with PennyPal's intuitive interface." + }, + testimonial: { + quote: "PennyPal transformed how I manage my finances. I finally have clarity on where my money goes and can make informed decisions about my spending.", + author: "Sarah Johnson" + } +}; + +export const features = [ + { + id: 'expense-tracking', + icon: DollarSign, + iconColor: 'text-primary', + bgColor: 'bg-primary/20', + title: 'Smart Expense Tracking', + titleColor: 'text-gray-800', + description: 'Categorize and analyze your spending patterns', + descriptionColor: 'text-primary' + }, + { + id: 'income-management', + icon: TrendingUp, + iconColor: 'text-accent', + bgColor: 'bg-primary/20', + title: 'Income Management', + titleColor: 'text-gray-800', + description: 'Monitor multiple income sources effortlessly', + descriptionColor: 'text-primary' + }, + { + id: 'financial-goals', + icon: Target, + iconColor: 'text-primary', + bgColor: 'bg-primary/20', + title: 'Financial Goals', + titleColor: 'text-primary-dark', + description: 'Set and achieve your savings targets', + descriptionColor: 'text-primary' + } +]; diff --git a/client/src/components/common/DashboardLayout.tsx b/client/src/components/common/DashboardLayout.tsx new file mode 100644 index 0000000..413ea1c --- /dev/null +++ b/client/src/components/common/DashboardLayout.tsx @@ -0,0 +1,15 @@ +import { Outlet } from "react-router-dom"; +import BackgroundImage from "./BackgroundImage"; +import DashboardHeader from "./Header"; +import Sidebar from "./Sidebar"; + +export default function DashboardLayout() { + return ( + <> + + + + + + ); +} diff --git a/client/src/components/common/Header.tsx b/client/src/components/common/Header.tsx new file mode 100644 index 0000000..c928411 --- /dev/null +++ b/client/src/components/common/Header.tsx @@ -0,0 +1,32 @@ +import { assets } from "../../assets/images"; +import { Link } from "react-router-dom"; +import ThemeToggle from "./ThemeToggle"; +import { useAuth } from "../../hooks/useAuth"; + +function Header() { + const { user } = useAuth(); + return ( +
+
+

Welcome, {user?.username || user?.firstname || user?.email || "User"}

+
+ +
+ + {" "} +
+ {user?.username || user?.firstname || "User"} + {user?.email || ""} +
+ + +
+
+ ); +} + +export default Header; + diff --git a/client/src/components/common/LogoutButton.tsx b/client/src/components/common/LogoutButton.tsx new file mode 100644 index 0000000..d99393c --- /dev/null +++ b/client/src/components/common/LogoutButton.tsx @@ -0,0 +1,34 @@ +import { Button } from "../../ui"; +import { useToast } from "../../ui"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../../hooks/useAuth"; +import type { ButtonProps } from "../../ui/Button"; + +type Props = Omit & { + children?: React.ReactNode; +}; + +const LogoutButton = ({ children = "Logout", ...btnProps }: Props) => { + const { logout } = useAuth(); + const toast = useToast(); + const navigate = useNavigate(); + + async function onLogout() { + try { + await logout(); + toast.success("Logged out. See you soon!"); + navigate("/login", { replace: true }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Logout failed"); + } + } + + return ( + + ); +} + +export default LogoutButton; +export { LogoutButton }; diff --git a/client/src/components/common/RequireAuth.tsx b/client/src/components/common/RequireAuth.tsx new file mode 100644 index 0000000..a1e8c07 --- /dev/null +++ b/client/src/components/common/RequireAuth.tsx @@ -0,0 +1,14 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useAuth } from "../../hooks/useAuth"; + +export default function RequireAuth() { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) return

Loading...

; + return user ? ( + + ) : ( + + ); +} diff --git a/client/src/components/common/Sidebar.tsx b/client/src/components/common/Sidebar.tsx index 2ade4c6..30d2744 100644 --- a/client/src/components/common/Sidebar.tsx +++ b/client/src/components/common/Sidebar.tsx @@ -5,13 +5,13 @@ import { User, LogOut, } from "lucide-react"; +import LogoutButton from "./LogoutButton"; const items = [ { label: "Dashboard", icon: LayoutDashboard, href: "/" }, { label: "Expenses", icon: CreditCard, href: "/expenses" }, { label: "Incomes", icon: DollarSign, href: "/incomes" }, { label: "Profile", icon: User, href: "/profile" }, - { label: "Logout", icon: LogOut, href: "#" }, ]; export default function Sidebar() { @@ -44,23 +44,35 @@ export default function Sidebar() {
{items.slice(3).map(({ label, icon: Icon, href }) => ( - + > - + {label} - - + + ))} + + } + > + + Logout + +
@@ -68,4 +80,4 @@ export default function Sidebar() {
); -} \ No newline at end of file +} diff --git a/client/src/hooks/useAuth.ts b/client/src/hooks/useAuth.ts new file mode 100644 index 0000000..f5ae360 --- /dev/null +++ b/client/src/hooks/useAuth.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ApiError, DefaultService, type PublicUser } from '../api'; +import { UserService } from '../services/UserService'; + +// SDK config is applied globally in src/sdkConfig.ts + +const extractErrorMessage = (err: unknown, fallback = 'Unexpected error'): string => { + if (err instanceof Error && err.message) return err.message; + if (typeof err === 'string' && err) return err; + try { + const obj = (err ?? {}) as Record; + if (obj && typeof obj === 'object') { + if (typeof obj.error === 'string') return obj.error; + if (typeof obj.message === 'string') return obj.message; + } + } catch { + // ignore + } + return fallback; +} + +type AuthAction = 'login' | 'signup' | 'logout' | 'me'; + +const toFriendlyAuthMessage = (err: unknown, action: AuthAction): string => { + const raw = extractErrorMessage(err, ''); + if (raw && /network|failed to fetch|ECONNREFUSED|timeout/i.test(raw)) { + return 'Network error. Please check your connection and try again.'; + } + + if (err instanceof ApiError) { + const status = err.status; + // optional server-provided error code/message + const body = (err as ApiError & { body?: unknown }).body ?? {}; + const b = body as Record; + const serverMsg: string | undefined = (typeof b.error === 'string' && b.error) + || (typeof b.message === 'string' && b.message) + || undefined; + + if (status === 401) { + if (action === 'login') return 'Invalid email or password.'; + if (action === 'me') return 'Your session has expired. Please sign in again.'; + return 'You are not authorized. Please sign in.'; + } + if (status === 409 && action === 'signup') { + return 'An account with this email already exists.'; + } + if (status === 429) { + return 'Too many attempts. Please try again later.'; + } + if (status === 400 || status === 422) { + if (action === 'signup') return 'Please check your details and try again.'; + if (action === 'login') return 'Please enter a valid email and password.'; + return 'Invalid request. Please try again.'; + } + if (status >= 500) { + return 'Something went wrong on our side. Please try again later.'; + } + if (serverMsg && typeof serverMsg === 'string' && serverMsg.length < 160) return serverMsg; + } + + switch (action) { + case 'login': + return 'Could not sign in. Please try again.'; + case 'signup': + return 'Could not create your account. Please try again.'; + case 'logout': + return 'Could not sign out. Please try again.'; + case 'me': + default: + return 'Could not verify your session. Please try again.'; + } +} + +export const useAuth = () => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const me = useCallback(async () => { + setError(null); + try { + const u = await DefaultService.getAuthMe(); + setUser(u); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + setUser(null); + return; + } + setUser(null); + setError(extractErrorMessage(err, 'Failed to fetch session')); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + me(); + }, [me]); + + const login = useCallback(async (email: string, password: string) => { + setLoading(true); + setError(null); + try { + await DefaultService.postAuthLogin({ email, password }); + const u = await DefaultService.getAuthMe(); + setUser(u); + return u; + } catch (err) { + const msg = toFriendlyAuthMessage(err, 'login'); + setError(msg); + throw err; + } finally { + setLoading(false); + } + }, []); + + const signup = useCallback( + async (params: { email: string; password: string; username?: string; firstname?: string; lastname?: string }) => { + setLoading(true); + setError(null); + try { + await DefaultService.postAuthSignup({ email: params.email, password: params.password }); + const u = await DefaultService.getAuthMe(); + setUser(u); + return u; + } catch (err) { + const msg = toFriendlyAuthMessage(err, 'signup'); + setError(msg); + throw err; + } finally { + setLoading(false); + } + }, + [] + ); + + const logout = useCallback(async () => { + setLoading(true); + setError(null); + try { + await DefaultService.postAuthLogout(); + setUser(null); + } catch (err) { + const msg = toFriendlyAuthMessage(err, 'logout'); + setError(msg); + throw err; + } finally { + setLoading(false); + } + }, []); + + const updateProfile = useCallback( + async (data: { username?: string; firstname?: string; lastname?: string }) => { + setLoading(true); + setError(null); + try { + await UserService.updateProfile(data); + await me(); + } catch (err) { + const msg = extractErrorMessage(err, 'Failed to update profile'); + setError(msg); + throw err; + } finally { + setLoading(false); + } + }, + [me] + ); + + const value = useMemo( + () => ({ user, loading, error, login, signup, logout, refresh: me, updateProfile }), + [user, loading, error, login, signup, logout, me, updateProfile] + ); + + return value; +} diff --git a/client/src/index.css b/client/src/index.css index 4590f71..9c67123 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -21,23 +21,101 @@ transition: border-color 0.25s; } button:hover { - border-color: #646cff; + border-color: #052261; } button:focus, button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + outline: none; + } + + /* Remove browser autofill background to keep underline-only TextField style */ + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus { + -webkit-text-fill-color: currentColor !important; + caret-color: currentColor !important; + background-color: transparent !important; + background-image: none !important; + box-shadow: 0 0 0 1000px transparent inset !important; + -webkit-box-shadow: 0 0 0 1000px transparent inset !important; + -webkit-background-clip: content-box !important; + transition: background-color 9999s ease-out 0s !important; + } + /* Firefox */ + input:-moz-autofill, + input:-moz-autofill:focus { + -moz-text-fill-color: currentColor !important; + caret-color: currentColor !important; + background-color: transparent !important; + background-image: none !important; + box-shadow: 0 0 0 1000px transparent inset !important; + transition: background-color 9999s ease-out 0s !important; } } /* Minimal utilities */ @layer utilities { + /* Auth forms use semi-transparent white background; mask autofill with same color */ + .auth-form input:-webkit-autofill, + .auth-form input:-webkit-autofill:hover, + .auth-form input:-webkit-autofill:focus { + -webkit-text-fill-color: currentColor !important; + caret-color: currentColor !important; + background-image: none !important; + /* Match bg-white/80 ≈ rgba(255,255,255,0.8) */ + box-shadow: 0 0 0 1000px rgba(255,255,255,0.8) inset !important; + -webkit-box-shadow: 0 0 0 1000px rgba(255,255,255,0.8) inset !important; + } + + .auth-form input:-moz-autofill, + .auth-form input:-moz-autofill:focus { + box-shadow: 0 0 0 1000px rgba(255,255,255,0.8) inset !important; + } + + .auth-form input:focus { + border-color: var(--color-primary-light) !important; + --tw-ring-color: var(--color-primary-light) !important; + outline: none !important; + } + @keyframes ripple { to { opacity: 0; transform: translate(-50%, -50%) scale(8); } } + + @keyframes slide { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } + } .btn-icon { @apply rounded-full p-3; } + + .scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--color-accent) transparent; + } + + .scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; + } + .scrollbar::-webkit-scrollbar-track { + background: transparent; + } + .scrollbar::-webkit-scrollbar-thumb { + background-color: var(--color-accent); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: content-box; + } + .scrollbar:hover::-webkit-scrollbar-thumb { + background-color: var(--color-accent); + } } diff --git a/client/src/main.tsx b/client/src/main.tsx index c3cbdd0..3b9c25b 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import { BrowserRouter } from 'react-router-dom' +import './sdkConfig' createRoot(document.getElementById('root')!).render( diff --git a/client/src/pages/AuthCallback.tsx b/client/src/pages/AuthCallback.tsx new file mode 100644 index 0000000..0239472 --- /dev/null +++ b/client/src/pages/AuthCallback.tsx @@ -0,0 +1,29 @@ +import { useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; + +export default function AuthCallback() { + const navigate = useNavigate(); + const location = useLocation() as ReturnType & { + state?: { from?: string } | null; + }; + const { refresh } = useAuth(); + + useEffect(() => { + // the backend should have set the session cookie. Just refresh the session and redirect. + const from = (location.state && typeof location.state === 'object' && location.state?.from) || "/"; + (async () => { + try { + await refresh(); + } finally { + navigate(from, { replace: true }); + } + })(); + }, [navigate, refresh, location.state]); + + return ( +
+
Finishing sign-in…
+
+ ); +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx new file mode 100644 index 0000000..6e498c6 --- /dev/null +++ b/client/src/pages/LoginPage.tsx @@ -0,0 +1,36 @@ +import { useLocation, useNavigate } from "react-router-dom"; +import { useToast } from "../ui"; +import { useAuth } from "../hooks/useAuth"; +import { type LoginFormData } from "../types/Auth"; +import LoginForm from "../components/auth/LoginForm"; +import AuthLayout from "../components/auth/AuthLayout"; + +export default function LoginPage() { + const { login, loading, error } = useAuth(); + const navigate = useNavigate(); + const location = useLocation() as ReturnType & { + state?: { from?: string } | null; + }; + const toast = useToast(); + const from = (location.state && typeof location.state === 'object' && location.state?.from) || "/"; + + async function onSubmit({ email, password }: LoginFormData) { + try { + await login(email, password); + toast.success("Signed in successfully"); + navigate(from, { replace: true }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Login failed"); + } + } + + return ( + Continue with Google or enter your details.} + > + + + ); +} diff --git a/client/src/pages/SignupPage.tsx b/client/src/pages/SignupPage.tsx new file mode 100644 index 0000000..94b4f5d --- /dev/null +++ b/client/src/pages/SignupPage.tsx @@ -0,0 +1,30 @@ +import { useToast } from "../ui"; +import { useAuth } from "../hooks/useAuth"; +import SignupForm from "../components/auth/SignupForm"; +import { type SignupFormData } from "../types/Auth"; +import AuthLayout from "../components/auth/AuthLayout"; + +export default function SignupPage() { + const { signup, loading, error } = useAuth(); + const toast = useToast(); + + async function onSubmit({ email, password }: SignupFormData) { + try { + await signup({ email, password }); + toast.success("Account created successfully"); + } catch (err) { + console.warn('Signup failed', err); + throw err; + } + } + + return ( + Continue with Google or fill in the details below.} + > + + + ); +} diff --git a/client/src/sdkConfig.ts b/client/src/sdkConfig.ts new file mode 100644 index 0000000..24feecd --- /dev/null +++ b/client/src/sdkConfig.ts @@ -0,0 +1,9 @@ + +import { OpenAPI } from './api'; + +// base URL: use env override or default to server dev URL +OpenAPI.BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8080/api'; + +// Credentials: enable cookies for auth-backed endpoints +OpenAPI.WITH_CREDENTIALS = true; +OpenAPI.CREDENTIALS = 'include'; diff --git a/client/src/services/UserService.ts b/client/src/services/UserService.ts index f0957a0..7032c05 100644 --- a/client/src/services/UserService.ts +++ b/client/src/services/UserService.ts @@ -30,7 +30,7 @@ export class UserService { profileData: UpdateProfileRequest ): Promise { try { - const response = await DefaultService.putUserProfile(profileData); + const response = await DefaultService.patchUserProfile(profileData); useMascotStore.getState().setExpression("success"); return response as UserProfile; } catch (error: unknown) { diff --git a/client/src/stories/Button.tsx b/client/src/stories/Button.tsx index f35dafd..d055c5d 100644 --- a/client/src/stories/Button.tsx +++ b/client/src/stories/Button.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import './button.css'; export interface ButtonProps { diff --git a/client/src/stories/Header.tsx b/client/src/stories/Header.tsx index 1bf981a..d05ed4f 100644 --- a/client/src/stories/Header.tsx +++ b/client/src/stories/Header.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { Button } from './Button'; import './header.css'; diff --git a/client/src/types/Auth.ts b/client/src/types/Auth.ts new file mode 100644 index 0000000..b949dc9 --- /dev/null +++ b/client/src/types/Auth.ts @@ -0,0 +1,64 @@ + +export type LoginFormData = { + email: string; + password: string; +}; + +export type LoginFormProps = { + onSubmit: (data: LoginFormData) => void | Promise; + loading?: boolean; + error?: string | null; + title?: string; + submitLabel?: string; + showSignupLink?: boolean; +}; + +export type SignupFormData = { + email: string; + password: string; + confirmPassword: string; +}; + +export type SignupFormProps = { + onSubmit: (data: SignupFormData) => void | Promise; + loading?: boolean; + error?: string | null; + title?: string; + submitLabel?: string; + showLoginLink?: boolean; +}; + +// Public user shape from server/services/auth.service.js `publicUserSelect` +export interface PublicUser { + user_id: number; + email: string; + username: string | null; + firstname: string | null; + lastname: string | null; + created_at: string; +} + +export interface AuthState { + user: PublicUser | null; + loading: boolean; + error: string | null; +} + +export type UsernameModalProps = { + open: boolean; + email: string; + username: string; + onUsernameChange: (value: string) => void; + onClose: () => void; + onSkip: () => void; + onSave: () => void; + saving?: boolean; +}; + +export type Category = { + category_id: number; + category_name: string; + icon_url?: string | null; + is_custom?: boolean; + user_id?: number | null; +}; \ No newline at end of file diff --git a/client/src/ui/Button.tsx b/client/src/ui/Button.tsx index bda9be1..b4cbbf8 100644 --- a/client/src/ui/Button.tsx +++ b/client/src/ui/Button.tsx @@ -100,7 +100,7 @@ const Button = forwardRef(({ relative inline-flex items-center justify-center select-none rounded-md font-medium whitespace-nowrap transition-[background,color,box-shadow,transform] duration-150 ease-out - focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-400 + outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 disabled:pointer-events-none disabled:opacity-60 `.trim().replace(/\s+/g, ' '), []); diff --git a/client/src/ui/Dialog.tsx b/client/src/ui/Dialog.tsx index 7b52303..dc62271 100644 --- a/client/src/ui/Dialog.tsx +++ b/client/src/ui/Dialog.tsx @@ -9,6 +9,7 @@ export interface DialogClasses { body?: string; footer?: string; close?: string; + panel?: string; } export interface DialogProps { @@ -62,7 +63,8 @@ const Dialog: React.FC = ({ const panelCls = [ 'w-full max-w-lg rounded-lg bg-white shadow-xl', 'border border-gray-200', - ].join(' '); + classes?.panel, + ].filter(Boolean).join(' '); const headerCls = ['px-5 pt-4 pb-2 flex items-start justify-between', classes?.header].filter(Boolean).join(' '); const titleCls = ['text-base font-semibold text-gray-900', classes?.title].filter(Boolean).join(' '); diff --git a/client/src/ui/IconButton.tsx b/client/src/ui/IconButton.tsx index 7757304..ab3923b 100644 --- a/client/src/ui/IconButton.tsx +++ b/client/src/ui/IconButton.tsx @@ -54,7 +54,7 @@ const IconButton = forwardRef(({ inline-flex items-center justify-center rounded-full transition-all duration-200 ease-in-out - focus:outline-none focus:ring-2 focus:ring-offset-2 + outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 disabled:pointer-events-none disabled:opacity-60 ${sizeClasses[size]} ${edgeClasses[edge]} diff --git a/client/vite.config.ts b/client/vite.config.ts index c4069b7..0c87922 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -5,4 +5,13 @@ import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + }, + }, + }, }) diff --git a/server/.env.example b/server/.env.example index 7e35b9b..da77433 100644 --- a/server/.env.example +++ b/server/.env.example @@ -18,6 +18,17 @@ DATABASE_URL=postgres://:@localhost:5432/ # JWT Configuration JWT_SECRET=your-super-secret-jwt-key-change-this-in-production +# Frontend URL (used for redirects after OAuth); falls back to CORS_ORIGIN if not set +FRONTEND_URL=http://localhost:5173 + # CORS Configuration (frontend url) CORS_ORIGIN=http://localhost:5173 +# Google OAuth +# Create OAuth 2.0 Client ID (Web) at https://console.cloud.google.com/apis/credentials +# Authorized redirect URI must include: +# http://localhost:8080/api/auth/google/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_OAUTH_TIMEOUT_MS=10000 + diff --git a/server/.gitignore b/server/.gitignore index 212fdbc..a58bca6 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -56,4 +56,3 @@ Thumbs.db .vercel/ /generated/prisma cookies.txt - diff --git a/server/controllers/auth.controller.js b/server/controllers/auth.controller.js index 731af43..099385f 100644 --- a/server/controllers/auth.controller.js +++ b/server/controllers/auth.controller.js @@ -1,8 +1,9 @@ import jwt from 'jsonwebtoken'; -import { signupUser, loginUser, getPublicUser } from '../services/auth.service.js'; +import { signupUser, loginUser, getPublicUser, upsertOAuthUser } from '../services/auth.service.js'; import { asyncHandler } from '../utils/asyncHandler.js'; import { BadRequestError } from '../utils/errors.js'; import isStrongPassword from 'validator/lib/isStrongPassword.js'; +import { buildGoogleAuthUrl, exchangeGoogleCodeForProfile } from '../services/oauth.service.js'; const TOKEN_COOKIE_NAME = 'token'; @@ -51,3 +52,42 @@ export const logout = asyncHandler(async (_req, res) => { res.clearCookie(TOKEN_COOKIE_NAME, { path: '/', sameSite: 'lax' }); return res.status(204).send(); }); + +const getFrontendBase = () => process.env.FRONTEND_URL || process.env.CORS_ORIGIN || 'http://localhost:5173'; + +const oauthCookieOpts = () => { + const isProd = process.env.NODE_ENV === 'production'; + return { httpOnly: true, secure: isProd, sameSite: isProd ? 'none' : 'lax', path: '/', maxAge: 10 * 60 * 1000 }; +} + +export const googleAuth = asyncHandler(async (req, res) => { + const redirectUri = `${req.protocol}://${req.get('host')}/api/auth/google/callback`; + const { url, state } = buildGoogleAuthUrl({ redirectUri }); + res.cookie('g_state', state, oauthCookieOpts()); + return res.redirect(url); +}); + +export const googleCallback = asyncHandler(async (req, res) => { + const { code, state } = req.query; + const savedState = req.cookies?.g_state; + if (!code || !state || !savedState || state !== savedState) { + throw new BadRequestError('Invalid OAuth state'); + } + res.clearCookie('g_state', { path: '/' }); + + const redirectUri = `${req.protocol}://${req.get('host')}/api/auth/google/callback`; + const profile = await exchangeGoogleCodeForProfile({ code, redirectUri }); + const email = profile?.email; + if (!email) throw new BadRequestError('Email not available from Google'); + + const publicUser = await upsertOAuthUser({ + email, + given_name: profile?.given_name, + family_name: profile?.family_name, + name: profile?.name, + }); + + setAuthCookie(res, { user_id: publicUser.user_id, email: publicUser.email }); + const frontend = getFrontendBase(); + return res.redirect(302, `${frontend.replace(/\/$/, '')}/auth/callback`); +}); diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml index 080b378..5dda509 100644 --- a/server/docs/Expense Tracker API.yaml +++ b/server/docs/Expense Tracker API.yaml @@ -143,12 +143,6 @@ paths: description: Expense updated delete: summary: Delete an expense - parameters: - - in: path - name: id - required: true - schema: - type: string responses: "204": description: Expense deleted @@ -221,12 +215,6 @@ paths: description: Income updated delete: summary: Delete an income entry - parameters: - - in: path - name: id - required: true - schema: - type: string responses: "204": description: Income deleted @@ -248,6 +236,9 @@ paths: properties: name: type: string + icon_url: + type: string + nullable: true responses: "201": description: Category created @@ -338,18 +329,38 @@ paths: responses: "200": description: Profile info - put: + patch: summary: Update user profile + description: Update username for the authenticated user requestBody: required: true content: application/json: schema: type: object + properties: + username: + type: string + minLength: 3 + maxLength: 50 + pattern: '^[A-Za-z0-9_-]{3,50}$' + firstname: + type: string + maxLength: 100 + lastname: + type: string + maxLength: 100 responses: "200": - description: Profile updated - + description: Public user + content: + application/json: + schema: + $ref: '#/components/schemas/PublicUser' + "400": + description: Invalid username + "401": + description: Unauthorized /user/profile/password: patch: summary: Update user password @@ -362,3 +373,44 @@ paths: responses: "200": description: Password updated + + /auth/me: + get: + summary: Get current authenticated user + responses: + "200": + description: Public user + content: + application/json: + schema: + $ref: '#/components/schemas/PublicUser' + "401": + description: Unauthorized + + /auth/logout: + post: + summary: Logout current session + responses: + "204": + description: No Content + +components: + schemas: + PublicUser: + type: object + required: [user_id, email, created_at] + properties: + user_id: + type: integer + email: + type: string + format: email + username: + type: [string, 'null'] + firstname: + type: [string, 'null'] + lastname: + type: [string, 'null'] + created_at: + type: string + format: date-time diff --git a/server/middleware/validate.js b/server/middleware/validate.js index ef141d0..aa799d8 100644 --- a/server/middleware/validate.js +++ b/server/middleware/validate.js @@ -1,7 +1,7 @@ -import { BadRequestError } from "../utils/errors.js"; -import isEmail from "validator/lib/isEmail.js"; -import normalizeEmail from "validator/lib/normalizeEmail.js"; -import isURL from "validator/lib/isURL.js"; +import { BadRequestError } from '../utils/errors.js'; +import isEmail from 'validator/lib/isEmail.js'; +import normalizeEmail from 'validator/lib/normalizeEmail.js'; +import isURL from 'validator/lib/isURL.js'; import isStrongPassword from 'validator/lib/isStrongPassword.js'; export const requireFields = @@ -64,19 +64,17 @@ export const validateIdParam = (paramName) => (req, _res, next) => { }; export const validateCategoryCreate = [ - requireFields("name"), - sanitizeBody("name", "icon_url"), + requireFields('name'), + sanitizeBody('name', 'icon_url'), validateTextMaxLengths({ name: 50 }), (_req, _res, next) => { const { icon_url } = _req.body; if (icon_url) { - if (typeof icon_url !== "string" || icon_url.length > 255) { - return next( - new BadRequestError("icon_url is too long (max 255 characters)") - ); + if (typeof icon_url !== 'string' || icon_url.length > 255) { + return next(new BadRequestError('icon_url is too long (max 255 characters)')); } if (!isURL(icon_url, { require_protocol: true })) { - return next(new BadRequestError("Invalid URL format for icon_url")); + return next(new BadRequestError('Invalid URL format for icon_url')); } } next(); @@ -84,21 +82,19 @@ export const validateCategoryCreate = [ ]; export const validateCategoryUpdate = [ - sanitizeBody("name", "icon_url"), + sanitizeBody('name', 'icon_url'), validateTextMaxLengths({ name: 50 }), (_req, _res, next) => { const { name, icon_url } = _req.body; - if (name != null && name === "") { - return next(new BadRequestError("name cannot be empty")); + if (name != null && name === '') { + return next(new BadRequestError('name cannot be empty')); } if (icon_url) { - if (typeof icon_url !== "string" || icon_url.length > 255) { - return next( - new BadRequestError("icon_url is too long (max 255 characters)") - ); + if (typeof icon_url !== 'string' || icon_url.length > 255) { + return next(new BadRequestError('icon_url is too long (max 255 characters)')); } if (!isURL(icon_url, { require_protocol: true })) { - return next(new BadRequestError("Invalid URL format for icon_url")); + return next(new BadRequestError('Invalid URL format for icon_url')); } } next(); diff --git a/server/package-lock.json b/server/package-lock.json index 76ba302..b25da66 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -20,6 +20,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", + "undici": "^7.15.0", "validator": "^13.11.0" }, "devDependencies": { @@ -2503,6 +2504,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz", + "integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/server/package.json b/server/package.json index 0c653ee..95bd8f4 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,8 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", - "prisma:studio": "prisma studio" + "prisma:studio": "prisma studio", + "prisma:seed": "node prisma/seed.js" }, "keywords": [ "express", @@ -32,10 +33,14 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", + "undici": "^7.15.0", "validator": "^13.11.0" }, "devDependencies": { "nodemon": "^3.0.2", "prisma": "^6.14.0" + }, + "prisma": { + "seed": "node prisma/seed.js" } } diff --git a/server/prisma/migrations/20250824191828_migration/migration.sql b/server/prisma/migrations/20250830164241_migration/migration.sql similarity index 68% rename from server/prisma/migrations/20250824191828_migration/migration.sql rename to server/prisma/migrations/20250830164241_migration/migration.sql index fa83842..80e71cc 100644 --- a/server/prisma/migrations/20250824191828_migration/migration.sql +++ b/server/prisma/migrations/20250830164241_migration/migration.sql @@ -1,11 +1,14 @@ +-- CreateEnum +CREATE TYPE "public"."ExpenseType" AS ENUM ('ONE_TIME', 'RECURRING'); + -- CreateTable CREATE TABLE "public"."user" ( "user_id" SERIAL NOT NULL, - "username" VARCHAR(50) NOT NULL, + "username" VARCHAR(50), "email" VARCHAR(255) NOT NULL, "hashed_password" VARCHAR(255) NOT NULL, - "firstname" VARCHAR(100) NOT NULL, - "lastname" VARCHAR(100) NOT NULL, + "firstname" VARCHAR(100), + "lastname" VARCHAR(100), "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL, @@ -25,19 +28,6 @@ CREATE TABLE "public"."expense_category" ( CONSTRAINT "expense_category_pkey" PRIMARY KEY ("category_id") ); --- CreateTable -CREATE TABLE "public"."income_category" ( - "category_id" SERIAL NOT NULL, - "category_name" VARCHAR(50) NOT NULL, - "icon_url" VARCHAR(500), - "is_custom" BOOLEAN NOT NULL DEFAULT false, - "user_id" INTEGER, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "income_category_pkey" PRIMARY KEY ("category_id") -); - -- CreateTable CREATE TABLE "public"."expense" ( "expense_id" SERIAL NOT NULL, @@ -57,16 +47,15 @@ CREATE TABLE "public"."expense" ( -- CreateTable CREATE TABLE "public"."income" ( - "income_id" SERIAL NOT NULL, + "id" SERIAL NOT NULL, "amount" DOUBLE PRECISION NOT NULL, "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "source" VARCHAR(100) NOT NULL, + "source" VARCHAR(100), "description" VARCHAR(2000), "creation_date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "user_id" INTEGER NOT NULL, - "category_id" INTEGER NOT NULL, - CONSTRAINT "income_pkey" PRIMARY KEY ("income_id") + CONSTRAINT "income_pkey" PRIMARY KEY ("id") ); -- CreateIndex @@ -75,9 +64,6 @@ CREATE UNIQUE INDEX "user_email_key" ON "public"."user"("email"); -- AddForeignKey ALTER TABLE "public"."expense_category" ADD CONSTRAINT "expense_category_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."user"("user_id") ON DELETE CASCADE ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "public"."income_category" ADD CONSTRAINT "income_category_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."user"("user_id") ON DELETE CASCADE ON UPDATE CASCADE; - -- AddForeignKey ALTER TABLE "public"."expense" ADD CONSTRAINT "expense_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."user"("user_id") ON DELETE CASCADE ON UPDATE CASCADE; @@ -86,6 +72,3 @@ ALTER TABLE "public"."expense" ADD CONSTRAINT "expense_category_id_fkey" FOREIGN -- AddForeignKey ALTER TABLE "public"."income" ADD CONSTRAINT "income_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."user"("user_id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."income" ADD CONSTRAINT "income_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "public"."income_category"("category_id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/seed.js b/server/prisma/seed.js new file mode 100644 index 0000000..e8089c9 --- /dev/null +++ b/server/prisma/seed.js @@ -0,0 +1,66 @@ +/* eslint-disable no-console */ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const DEFAULT_CATEGORIES = [ + { category_name: 'Rent', icon_url: null }, + { category_name: 'Electricity', icon_url: null }, + { category_name: 'Water', icon_url: null }, + { category_name: 'Internet', icon_url: null }, + { category_name: 'Phone', icon_url: null }, + { category_name: 'Transport', icon_url: null }, + { category_name: 'Fuel', icon_url: null }, + { category_name: 'Parking', icon_url: null }, + { category_name: 'Groceries', icon_url: null }, + { category_name: 'Restaurant', icon_url: null }, + { category_name: 'Entertainment', icon_url: null }, + { category_name: 'Subscriptions', icon_url: null }, + { category_name: 'Health', icon_url: null }, + { category_name: 'Pharmacy', icon_url: null }, + { category_name: 'Insurance', icon_url: null }, + { category_name: 'Education', icon_url: null }, + { category_name: 'Gifts & Donations', icon_url: null }, + { category_name: 'Travel', icon_url: null }, + { category_name: 'Personal Care', icon_url: null }, + { category_name: 'Misc', icon_url: null }, +]; + +const main = async () => { + console.log('Seeding global expense categories (user_id: null)...'); + + for (const item of DEFAULT_CATEGORIES) { + const exists = await prisma.expenseCategory.findFirst({ + where: { + user_id: null, + category_name: { equals: item.category_name, mode: 'insensitive' }, + }, + }); + + if (exists) { + console.log(`exists: ${item.category_name}`); + continue; + } + + await prisma.expenseCategory.create({ + data: { + category_name: item.category_name, + icon_url: item.icon_url, + is_custom: false, + user_id: null, + }, + }); + console.log(`created: ${item.category_name}`); + } + + console.log('Seed completed.'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/server/routes/auth.route.js b/server/routes/auth.route.js index df4032c..9fcb0d3 100644 --- a/server/routes/auth.route.js +++ b/server/routes/auth.route.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { signup, login, logout, me } from '../controllers/auth.controller.js'; +import { signup, login, logout, me, googleAuth, googleCallback } from '../controllers/auth.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; import { validateSignup, validateLogin } from '../middleware/validate.js'; @@ -9,5 +9,7 @@ router.post('/signup', validateSignup, signup); router.post('/login', validateLogin, login); router.post('/logout', logout); router.get('/me', requireAuth, me); +router.get('/google', googleAuth); +router.get('/google/callback', googleCallback); export default router; diff --git a/server/routes/user.route.js b/server/routes/user.route.js index 893e653..4d2bd5e 100644 --- a/server/routes/user.route.js +++ b/server/routes/user.route.js @@ -12,7 +12,7 @@ import { const router = Router(); router.get("/profile", getUserProfile); -router.put("/profile", validateUpdateProfile, updateProfile); +router.patch("/profile", validateUpdateProfile, updateProfile); router.patch("/profile/password", validateChangePassword, changePassword); export default router; diff --git a/server/server.js b/server/server.js index b62de41..43e2c0d 100644 --- a/server/server.js +++ b/server/server.js @@ -1,34 +1,50 @@ -import express from "express"; -import cors from "cors"; -import dotenv from "dotenv"; -import cookieParser from "cookie-parser"; -import { requireAuth } from "./middleware/auth.middleware.js"; -import { PrismaClient } from "@prisma/client"; -import incomeRoutes from "./routes/income.route.js"; -import authRoutes from "./routes/auth.route.js"; -import categoryRoutes from "./routes/category.route.js"; -import userRoutes from "./routes/user.route.js"; +import dns from 'node:dns'; +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import cookieParser from 'cookie-parser'; +import helmet from 'helmet'; +import { requireAuth } from './middleware/auth.middleware.js'; +import { PrismaClient } from '@prisma/client'; +import incomeRoutes from './routes/income.route.js'; +import authRoutes from './routes/auth.route.js'; +import categoryRoutes from './routes/category.route.js'; +import userRoutes from './routes/user.route.js'; import expenseRoutes from './routes/expense.route.js'; +import { configureNetwork } from './utils/network.js'; dotenv.config(); +// Prefer IPv4 to mitigate environments where IPv6 routes time out +try { + dns.setDefaultResultOrder('ipv4first'); +} catch {} + +try { + await configureNetwork(); +} catch {} + const app = express(); const PORT = process.env.PORT || 8080; app.use( - cors({ - origin: process.env.CORS_ORIGIN || true, - credentials: true, + helmet({ + contentSecurityPolicy: process.env.NODE_ENV === 'production' ? undefined : false, }) ); + +app.use(cors({ + origin: process.env.CORS_ORIGIN || true, + credentials: true, +})); app.use(express.json()); app.use(cookieParser()); -app.use("/api/auth", authRoutes); -app.use("/api/incomes", requireAuth, incomeRoutes); -app.use("/api/categories", categoryRoutes); -app.use("/api/user", requireAuth, userRoutes); -app.use('/api/expenses', expenseRoutes) +app.use('/api/auth', authRoutes); +app.use('/api/incomes', requireAuth, incomeRoutes); +app.use('/api/categories', categoryRoutes); +app.use('/api/user', requireAuth, userRoutes); +app.use('/api/expenses', expenseRoutes); // Initialize a single Prisma client instance const prisma = new PrismaClient(); diff --git a/server/services/auth.service.js b/server/services/auth.service.js index 0e61ff4..b27d382 100644 --- a/server/services/auth.service.js +++ b/server/services/auth.service.js @@ -53,6 +53,40 @@ export const getPublicUser = async (userId) => { return user; }; +export const upsertOAuthUser = async ({ email, given_name, family_name, name }) => { + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return { + user_id: existing.user_id, + email: existing.email, + username: existing.username, + firstname: existing.firstname, + lastname: existing.lastname, + created_at: existing.created_at, + }; + } + + const randomPass = `oauth-${Math.random().toString(36).slice(2)}-${Date.now()}`; + const hashed_password = await bcrypt.hash(randomPass, 10); + const preferred = (given_name && String(given_name).trim()) + || (name && String(name).trim()) + || (email && String(email).split('@')[0]) + || 'user'; + const username = preferred.slice(0, 50); + + const user = await prisma.user.create({ + data: { + email, + hashed_password, + username, + firstname: given_name || name || '', + lastname: family_name || '', + }, + select: publicUserSelect, + }); + return user; +}; + //-------------------------------------------------------- //UPDATE USER, in order to allow user to change their profile informations @@ -87,4 +121,4 @@ export const changeUserPassword = async (userId, { currentPassword, newPassword }); return { message: 'Password updated successfully' }; -}; \ No newline at end of file +}; diff --git a/server/services/oauth.service.js b/server/services/oauth.service.js new file mode 100644 index 0000000..41b6eec --- /dev/null +++ b/server/services/oauth.service.js @@ -0,0 +1,72 @@ +import crypto from 'crypto'; +import { BadRequestError } from '../utils/errors.js'; +import { fetchWithTimeout } from '../utils/http.js'; + +const GOOGLE_AUTH_BASE = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'; + +export const buildGoogleAuthUrl = ({ redirectUri }) => { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; // only to validate presence + if (!clientId || !clientSecret) throw new BadRequestError('Google OAuth not configured'); + + const state = crypto.randomBytes(16).toString('hex'); + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + access_type: 'offline', + prompt: 'consent', + }); + return { url: `${GOOGLE_AUTH_BASE}?${params.toString()}`, state }; +}; + +export const exchangeGoogleCodeForProfile = async ({ code, redirectUri }) => { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + if (!clientId || !clientSecret) throw new BadRequestError('Google OAuth not configured'); + + const body = new URLSearchParams({ + code: String(code), + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }).toString(); + + let tokenResp; + try { + tokenResp = await fetchWithTimeout(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + } catch (e) { + throw new BadRequestError(`Token exchange network error: ${(e && e.message) || e}`); + } + if (!tokenResp.ok) { + const t = await tokenResp.text().catch(() => ''); + throw new BadRequestError(`Token exchange failed: ${t || tokenResp.status}`); + } + const tokenJson = await tokenResp.json(); + const accessToken = tokenJson.access_token; + if (!accessToken) throw new BadRequestError('Missing access token'); + + let userResp; + try { + userResp = await fetchWithTimeout(GOOGLE_USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + } catch (e) { + throw new BadRequestError(`Userinfo network error: ${(e && e.message) || e}`); + } + if (!userResp.ok) { + const t = await userResp.text().catch(() => ''); + throw new BadRequestError(`Failed to fetch userinfo: ${t || userResp.status}`); + } + const profile = await userResp.json(); + return profile; +}; diff --git a/server/utils/http.js b/server/utils/http.js new file mode 100644 index 0000000..40950ac --- /dev/null +++ b/server/utils/http.js @@ -0,0 +1,11 @@ +// Generic fetch helper with timeout and clear error messages +export const fetchWithTimeout = async (input, init = {}) => { + const timeoutMs = Number(process.env.GOOGLE_OAUTH_TIMEOUT_MS || 10000); + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(new Error('Request timed out')), timeoutMs); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(id); + } +}; diff --git a/server/utils/network.js b/server/utils/network.js new file mode 100644 index 0000000..65c4909 --- /dev/null +++ b/server/utils/network.js @@ -0,0 +1,15 @@ +// Configure global fetch (Undici) to prefer IPv4 and honor HTTPS_PROXY/HTTP_PROXY if available. +// Works even if 'undici' package isn't installed: it will no-op gracefully. +export const configureNetwork = async () => { + try { + const { setGlobalDispatcher, Agent, ProxyAgent } = await import('undici'); + const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + if (proxyUrl && ProxyAgent) { + setGlobalDispatcher(new ProxyAgent(proxyUrl)); + } else { + setGlobalDispatcher(new Agent({ connect: { family: 4 } })); + } + } catch { + // undici not available; skip configuration + } +};