Initial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
DATABASE_URL=file:./data/medassist.db
|
||||||
|
CORS_ORIGINS=http://localhost:4173,http://localhost:5173
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
REFRESH_SECRET=change-me-too
|
||||||
|
COOKIE_SECRET=change-me-cookie
|
||||||
|
CSRF_SECRET=change-me-csrf
|
||||||
|
ACCESS_TOKEN_TTL_MIN=15
|
||||||
|
REFRESH_TOKEN_TTL_DAYS=14
|
||||||
|
|
||||||
|
# SMTP (optional)
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=
|
||||||
|
SMTP_SECURE=false
|
||||||
|
|
||||||
|
# Planner limits
|
||||||
|
EMAILS_PER_DAY=3
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# SQLite
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db-journal
|
||||||
|
backend/data/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Medassist (Rebuild)
|
||||||
|
|
||||||
|
Sichere, schlanke Neuimplementierung mit Fastify + SQLite + React/Vite. Docker-first, Caddy übernimmt TLS.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
- Backend: Fastify, SQLite (Drizzle/Kysely/Prisma ready), Auth mit HttpOnly-Cookies (Browser) + Bearer (API). Helmet, CORS-Allowlist, Rate Limit, CSRF double-submit, Input-Validation (zod/ajv).
|
||||||
|
- Frontend: React + Vite (TS). Geschützte Views, zentraler API-Client.
|
||||||
|
- Tokens: Access ~15m, Refresh rotierend (sliding) mit Max-Age ~14d, Reuse-Detection.
|
||||||
|
- Planner/Email: Server-escaped, Throttling, SMTP-Pass write-only.
|
||||||
|
- Deployment: Docker Compose (app + sqlite volume). Caddy als vorgelagerter Proxy/TLS.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
- Node-Version: siehe .nvmrc
|
||||||
|
- Env: .env.example kopieren → .env
|
||||||
|
- Workspaces: root package.json mit backend/frontend Workspaces
|
||||||
|
- Scripts (nach npm install in beiden Paketen):
|
||||||
|
- Backend: `npm run dev` (backend), `npm run build`, `npm run start`
|
||||||
|
- Frontend: `npm run dev`, `npm run build`, `npm run preview`
|
||||||
|
- Compose: `docker-compose up --build`
|
||||||
|
|
||||||
|
## Verzeichnisstruktur
|
||||||
|
- backend/ … Fastify-App, Migrations, Dockerfile
|
||||||
|
- frontend/ … React/Vite-App, Dockerfile
|
||||||
|
- docker-compose.yml … lokale Orchestrierung
|
||||||
|
|
||||||
|
## Security Defaults
|
||||||
|
- Keine Secrets in Logs/Responses
|
||||||
|
- CSRF nur für Cookie-Clients
|
||||||
|
- CORS-Liste aus ENV
|
||||||
|
- Non-root Container, Healthcheck
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
- Dependencies installieren
|
||||||
|
- DB-Migrationen ausführen
|
||||||
|
- Frontend-Routen/Views ausbauen
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Backend build
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json tsconfig.json drizzle.config.ts ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY data ./data
|
||||||
|
RUN npm install
|
||||||
|
RUN npm run build
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
# Runtime
|
||||||
|
FROM node:22-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/data ./data
|
||||||
|
COPY package.json .
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
const dbUrl = process.env.DATABASE_URL || "file:./data/medassist.db";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "libsql",
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dbCredentials: {
|
||||||
|
url: dbUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "medassist-backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"migrate": "tsx src/db/migrate.ts",
|
||||||
|
"lint": "echo 'add lint config'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^10.0.1",
|
||||||
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@fastify/helmet": "^11.1.1",
|
||||||
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/rate-limit": "^10.1.0",
|
||||||
|
"@fastify/sensible": "^5.0.1",
|
||||||
|
"@libsql/client": "^0.10.0",
|
||||||
|
"argon2": "^0.40.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"drizzle-orm": "^0.32.2",
|
||||||
|
"fastify": "^5.0.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.7.4",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createClient } from "@libsql/client";
|
||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL || "file:./data/medassist.db";
|
||||||
|
const client = createClient({ url });
|
||||||
|
|
||||||
|
export const db = drizzle(client);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import { db } from "./client.js";
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = join(__filename, "..");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const migrationsFolder = join(__dirname, "migrations");
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
console.log("Migrations applied");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const users = sqliteTable("users", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
email: text("email", { length: 255 }).notNull().unique(),
|
||||||
|
passwordHash: text("password_hash", { length: 255 }).notNull(),
|
||||||
|
role: text("role", { length: 50 }).notNull().default("user"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const medications = sqliteTable("medications", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
name: text("name", { length: 100 }).notNull().unique(),
|
||||||
|
count: integer("count").notNull().default(0),
|
||||||
|
usageJson: text("usage_json").notNull().default("[]"),
|
||||||
|
everyJson: text("every_json").notNull().default("[]"),
|
||||||
|
startJson: text("start_json").notNull().default("[]"),
|
||||||
|
stripSize: integer("strip_size").notNull().default(1),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const refreshTokens = sqliteTable("refresh_tokens", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
tokenId: text("token_id", { length: 255 }).notNull().unique(),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
|
rotatedAt: integer("rotated_at", { mode: "timestamp" }),
|
||||||
|
revoked: integer("revoked", { mode: "boolean" }).notNull().default(false),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settings = sqliteTable("settings", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
smtpHost: text("smtp_host"),
|
||||||
|
smtpPort: integer("smtp_port"),
|
||||||
|
smtpUser: text("smtp_user"),
|
||||||
|
smtpPassEncrypted: text("smtp_pass_encrypted"),
|
||||||
|
smtpFrom: text("smtp_from"),
|
||||||
|
smtpSecure: integer("smtp_secure", { mode: "boolean" }).notNull().default(false),
|
||||||
|
emailsPerDay: integer("emails_per_day").notNull().default(3),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import helmet from "@fastify/helmet";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import sensible from "@fastify/sensible";
|
||||||
|
import cookie, { CookieSerializeOptions } from "@fastify/cookie";
|
||||||
|
import jwt from "@fastify/jwt";
|
||||||
|
import { env } from "./plugins/env.js";
|
||||||
|
import { healthRoutes } from "./routes/health.js";
|
||||||
|
import { authRoutes } from "./routes/auth.js";
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: env.LOG_LEVEL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const origins = env.CORS_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const accessTtlMinutes = parseInt(env.ACCESS_TOKEN_TTL_MIN, 10);
|
||||||
|
const refreshTtlDays = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10);
|
||||||
|
|
||||||
|
const baseCookieOptions: CookieSerializeOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: accessTtlMinutes * 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshCookieOptions: CookieSerializeOptions = {
|
||||||
|
...baseCookieOptions,
|
||||||
|
maxAge: refreshTtlDays * 24 * 60 * 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
app.decorate("config", {
|
||||||
|
accessSecret: env.JWT_SECRET,
|
||||||
|
refreshSecret: env.REFRESH_SECRET,
|
||||||
|
accessTtl: accessTtlMinutes,
|
||||||
|
refreshTtl: refreshTtlDays,
|
||||||
|
cookieOptions: baseCookieOptions,
|
||||||
|
refreshCookieOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(sensible);
|
||||||
|
await app.register(helmet);
|
||||||
|
await app.register(cors, { origin: origins, credentials: true });
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
max: 100,
|
||||||
|
timeWindow: "1 minute",
|
||||||
|
});
|
||||||
|
await app.register(cookie, { secret: env.COOKIE_SECRET });
|
||||||
|
await app.register(jwt, { secret: env.JWT_SECRET, cookie: { cookieName: "access_token", signed: false } });
|
||||||
|
|
||||||
|
await app.register(healthRoutes);
|
||||||
|
await app.register(authRoutes);
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
try {
|
||||||
|
await app.listen({ port: env.PORT, host: "0.0.0.0" });
|
||||||
|
app.log.info(`Server running on ${env.PORT}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
start();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||||
|
|
||||||
|
const EnvSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||||
|
PORT: z.string().transform((v) => parseInt(v, 10)).default("3000"),
|
||||||
|
DATABASE_URL: z.string().default("file:./data/medassist.db"),
|
||||||
|
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||||
|
LOG_LEVEL: z.string().default("info"),
|
||||||
|
JWT_SECRET: z.string().min(10),
|
||||||
|
REFRESH_SECRET: z.string().min(10),
|
||||||
|
COOKIE_SECRET: z.string().min(10),
|
||||||
|
CSRF_SECRET: z.string().min(10),
|
||||||
|
ACCESS_TOKEN_TTL_MIN: z.string().default("15"),
|
||||||
|
REFRESH_TOKEN_TTL_DAYS: z.string().default("14"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof EnvSchema>;
|
||||||
|
|
||||||
|
export const env: Env = EnvSchema.parse(process.env);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import argon2 from "argon2";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { users, refreshTokens } from "../db/schema.js";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
const loginBody = z.object({ email: z.string().email(), password: z.string().min(6) });
|
||||||
|
|
||||||
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
|
app.post("/auth/login", { config: { csrf: true } }, async (req, reply) => {
|
||||||
|
const parsed = loginBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.badRequest("Invalid credentials");
|
||||||
|
}
|
||||||
|
const { email, password } = parsed.data;
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.email, email));
|
||||||
|
if (!user) return reply.unauthorized();
|
||||||
|
const ok = await argon2.verify(user.passwordHash, password);
|
||||||
|
if (!ok) return reply.unauthorized();
|
||||||
|
|
||||||
|
const accessToken = app.jwt.sign({ sub: user.id, role: user.role }, { expiresIn: `${app.config.accessTtl}m` });
|
||||||
|
const tokenId = randomUUID();
|
||||||
|
const refreshExp = Math.floor(Date.now() / 1000) + app.config.refreshTtl * 24 * 60 * 60;
|
||||||
|
await db.insert(refreshTokens).values({ userId: user.id, tokenId, expiresAt: new Date(refreshExp * 1000) });
|
||||||
|
const refreshToken = app.jwt.sign({ sub: user.id, jti: tokenId }, { expiresIn: `${app.config.refreshTtl}d`, key: app.config.refreshSecret });
|
||||||
|
|
||||||
|
reply
|
||||||
|
.setCookie("access_token", accessToken, app.config.cookieOptions)
|
||||||
|
.setCookie("refresh_token", refreshToken, app.config.refreshCookieOptions)
|
||||||
|
.send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/auth/logout", async (req, reply) => {
|
||||||
|
reply
|
||||||
|
.clearCookie("access_token", app.config.cookieOptions)
|
||||||
|
.clearCookie("refresh_token", app.config.refreshCookieOptions)
|
||||||
|
.send({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
export async function healthRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/health", async () => ({ status: "ok" }));
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
import "fastify";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
config: {
|
||||||
|
accessSecret: string;
|
||||||
|
refreshSecret: string;
|
||||||
|
accessTtl: number;
|
||||||
|
refreshTtl: number;
|
||||||
|
cookieOptions: import("@fastify/cookie").CookieSerializeOptions;
|
||||||
|
refreshCookieOptions: import("@fastify/cookie").CookieSerializeOptions;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./backend/data:/app/data
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
ports:
|
||||||
|
- "4173:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Frontend build
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json tsconfig.json tsconfig.node.json vite.config.ts index.html ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY public ./public
|
||||||
|
RUN npm install
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Runtime
|
||||||
|
FROM nginx:1.27-alpine AS runner
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Medassist</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:3000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "medassist-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "echo 'add lint config'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.4",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^7.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const healthSchema = z.object({ status: z.string() });
|
||||||
|
|
||||||
|
function useHealth() {
|
||||||
|
const [status, setStatus] = useState<string>("loading");
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/health", { credentials: "include" })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const parsed = healthSchema.safeParse(data);
|
||||||
|
if (parsed.success) setStatus(parsed.data.status);
|
||||||
|
else setStatus("error");
|
||||||
|
})
|
||||||
|
.catch(() => setStatus("error"));
|
||||||
|
}, []);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const status = useHealth();
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<div className="card">
|
||||||
|
<h1>Medassist (Rebuild)</h1>
|
||||||
|
<p>Backend health: {status}</p>
|
||||||
|
<p>Frontend scaffold ready. Auth & CRUD folgen.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background: #0f1115;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #1b1f2a;
|
||||||
|
border: 1px solid #252b3b;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 12px 30px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #2f86f6;
|
||||||
|
background: #2f86f6;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #30384a;
|
||||||
|
background: #111521;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+3421
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "medassist-monorepo",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"workspaces": [
|
||||||
|
"backend",
|
||||||
|
"frontend"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev --workspaces",
|
||||||
|
"build": "npm run build --workspaces",
|
||||||
|
"lint": "npm run lint --workspaces",
|
||||||
|
"test": "npm run test --workspaces"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user