Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyAuthHeader, setAccessTokenGetter } from "./client";
|
||||
|
||||
describe("apiClient auth header", () => {
|
||||
it("attaches bearer token from getter", () => {
|
||||
setAccessTokenGetter(() => "test-token");
|
||||
const headers = applyAuthHeader({});
|
||||
expect(headers.Authorization).toBe("Bearer test-token");
|
||||
});
|
||||
|
||||
it("leaves headers unchanged without token", () => {
|
||||
setAccessTokenGetter(() => null);
|
||||
const headers = applyAuthHeader({ "X-Test": "1" });
|
||||
expect(headers).toEqual({ "X-Test": "1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
|
||||
import { useAuthStore } from "@modules/auth/store/authStore";
|
||||
import { getApiBaseUrl } from "./config";
|
||||
|
||||
const apiBaseUrl = getApiBaseUrl();
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
timeout: 10_000
|
||||
});
|
||||
|
||||
export const authClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
timeout: 10_000,
|
||||
withCredentials: true
|
||||
});
|
||||
let getAccessToken: () => string | null = () => null;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
export function setAccessTokenGetter(getter: () => string | null): void {
|
||||
getAccessToken = getter;
|
||||
}
|
||||
|
||||
export function applyAuthHeader(headers: Record<string, string>): Record<string, string> {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
return { ...headers, Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function bootstrapSessionRefresh(): Promise<string | null> {
|
||||
return refreshAccessToken();
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = authClient
|
||||
.post("/api/v1/auth/refresh")
|
||||
.then((response) => {
|
||||
const { access_token: accessToken, user } = response.data;
|
||||
useAuthStore.getState().setSession(accessToken, user);
|
||||
return accessToken as string;
|
||||
})
|
||||
.catch(() => {
|
||||
useAuthStore.getState().clearSession();
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
config.headers = applyAuthHeader(config.headers as Record<string, string>) as typeof config.headers;
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const detail =
|
||||
error.response && typeof error.response.data === "object" && error.response.data !== null
|
||||
? (error.response.data as { detail?: string }).detail
|
||||
: undefined;
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(detail === "ACCOUNT_BLOCKED" || detail === "EMAIL_NOT_VERIFIED")
|
||||
) {
|
||||
useAuthStore.getState().clearSession();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (error.response?.status === 401 && detail === "TOKEN_REVOKED") {
|
||||
useAuthStore.getState().clearSession();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (originalRequest.url?.includes("/api/v1/auth/")) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
const accessToken = await refreshAccessToken();
|
||||
if (!accessToken) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
originalRequest.headers = applyAuthHeader(
|
||||
originalRequest.headers as Record<string, string>
|
||||
) as typeof originalRequest.headers;
|
||||
return apiClient.request(originalRequest);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getApiBaseUrl(): string {
|
||||
if (import.meta.env.VITE_USE_API_PROXY === "false") {
|
||||
return import.meta.env.VITE_API_URL ?? "http://localhost:8000";
|
||||
}
|
||||
if (import.meta.env.DEV || import.meta.env.VITE_USE_API_PROXY === "true") {
|
||||
return "";
|
||||
}
|
||||
return import.meta.env.VITE_API_URL ?? "http://localhost:8000";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, type PropsWithChildren } from "react";
|
||||
|
||||
export function ZootechThemeProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const previousTheme = root.getAttribute("data-theme");
|
||||
root.setAttribute("data-theme", "organic");
|
||||
|
||||
return () => {
|
||||
if (previousTheme) {
|
||||
root.setAttribute("data-theme", previousTheme);
|
||||
return;
|
||||
}
|
||||
root.removeAttribute("data-theme");
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
@import "../../../../main/css/tokens.css";
|
||||
|
||||
.z-login-shell {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
background: var(--bg-page);
|
||||
color: var(--text);
|
||||
font-family: var(--zootech-font);
|
||||
}
|
||||
|
||||
.z-login-page {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.z-login-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--zt-radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.z-login-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.z-login-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.z-login-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.z-login-field-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.z-login-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
background: var(--primary-tint);
|
||||
color: var(--text);
|
||||
padding: 12px 12px 12px 40px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.z-login-submit {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.z-login-submit:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.z-login-meta,
|
||||
.z-login-msg {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.z-login-meta {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.z-login-msg--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.z-login-msg--success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.z-login-remember {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.z-login-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.z-login-link {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@modules/auth";
|
||||
import { Button } from "@shared/ui";
|
||||
|
||||
export function AppHeader(): JSX.Element {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "1rem 1.5rem",
|
||||
borderBottom: "1px solid var(--muted)"
|
||||
}}
|
||||
>
|
||||
<nav style={{ display: "flex", gap: "1rem" }}>
|
||||
<a href="/">Home</a>
|
||||
{auth.isAuthenticated ? (
|
||||
<>
|
||||
<Link to="/profile">Profile</Link>
|
||||
<a href="/recipes">Zootech</a>
|
||||
{auth.user?.role === "admin" ? (
|
||||
<>
|
||||
<a href="/admin">Admin</a>
|
||||
{auth.user.is_superuser ? <Link to="/platform">Platform</Link> : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login">Login</Link>
|
||||
<Link to="/register">Register</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
{auth.isAuthenticated ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void auth.logout().then(() => {
|
||||
navigate("/login", { replace: true });
|
||||
});
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Button } from "./Button";
|
||||
|
||||
describe("Button", () => {
|
||||
it("renders button text", () => {
|
||||
render(<Button>Click</Button>);
|
||||
expect(screen.getByRole("button", { name: "Click" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ButtonHTMLAttributes, CSSProperties, PropsWithChildren } from "react";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
const variantStyles: Record<Variant, string> = {
|
||||
primary: "background: var(--primary); color: white; border: 0;",
|
||||
secondary: "background: var(--marquee-bg); color: var(--foreground); border: 0;",
|
||||
ghost: "background: transparent; color: var(--foreground); border: 1px solid var(--muted);"
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = "primary",
|
||||
...props
|
||||
}: PropsWithChildren<ButtonProps>): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
style={{
|
||||
padding: "0.65rem 1rem",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
...(Object.fromEntries(
|
||||
variantStyles[variant]
|
||||
.split(";")
|
||||
.map((rule) => rule.trim())
|
||||
.filter(Boolean)
|
||||
.map((rule) => {
|
||||
const [key, value] = rule.split(":");
|
||||
return [key.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase()), value.trim()];
|
||||
})
|
||||
) as CSSProperties)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
export function Input(props: InputHTMLAttributes<HTMLInputElement>): JSX.Element {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.6rem 0.75rem",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--muted)"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Button } from "./Button/Button";
|
||||
export { Input } from "./Input/Input";
|
||||
export { AppHeader } from "./AppHeader/AppHeader";
|
||||
Reference in New Issue
Block a user