auth media

This commit is contained in:
2026-03-14 23:16:06 +01:00
parent e587480abb
commit ab999c558d
26 changed files with 4219 additions and 381 deletions

View File

@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html/auth
EXPOSE 80

View File

@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
location /auth/ {
try_files $uri $uri/ /auth/index.html;
}
}

View File

@@ -0,0 +1,28 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
}
);

13
auth-view/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/auth/favicon.ico" sizes="any" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auth Post Hub</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3688
auth-view/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
auth-view/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "auth-view",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.0.2"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@tailwindcss/postcss": "^4.1.18",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"autoprefixer": "^10.4.23",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.8.3",
"vite": "^7.1.7"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

15
auth-view/src/App.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
export default function App() {
return (
<BrowserRouter basename="/auth">
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</BrowserRouter>
);
}

1
auth-view/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

10
auth-view/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,178 @@
import { useEffect, useState, FormEvent } from "react";
import { useSearchParams, Link } from "react-router-dom";
function saveTokensAndRedirect(token: string, refreshToken: string, redirect: string) {
localStorage.setItem("token", token);
localStorage.setItem("refreshToken", refreshToken);
window.location.href = redirect;
}
export default function LoginPage() {
const [searchParams] = useSearchParams();
const redirect = searchParams.get("redirect") ?? "/ragview";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
// Handle OAuth2 callback — token & refreshToken in URL
useEffect(() => {
const token = searchParams.get("token");
const refreshToken = searchParams.get("refreshToken");
if (token && refreshToken) {
saveTokensAndRedirect(token, refreshToken, redirect);
}
}, [searchParams, redirect]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (data.success && data.payload?.token) {
saveTokensAndRedirect(data.payload.token, data.payload.refreshToken ?? "", redirect);
} else {
setError(data.message ?? "Login failed");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
}
const registerHref = `/register?redirect=${encodeURIComponent(redirect)}`;
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Card */}
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-8 shadow-2xl">
<h1 className="text-2xl font-bold text-white mb-2 text-center">Welcome back</h1>
<p className="text-gray-400 text-sm text-center mb-8">Sign in to your account</p>
{/* Email/Password form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Email
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Password
</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
{error && (
<p className="text-red-400 text-sm bg-red-950/50 border border-red-800 rounded-lg px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg py-2.5 text-sm transition"
>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-3 my-6">
<div className="flex-1 h-px bg-gray-800" />
<span className="text-gray-500 text-xs font-medium">or</span>
<div className="flex-1 h-px bg-gray-800" />
</div>
{/* OAuth2 buttons */}
<div className="space-y-3">
<a
href="/oauth2/authorization/google"
className="flex items-center gap-3 w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-200 rounded-lg px-4 py-2.5 text-sm font-medium transition"
>
<GoogleIcon />
Login with Google
</a>
<a
href="/oauth2/authorization/github"
className="flex items-center gap-3 w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-200 rounded-lg px-4 py-2.5 text-sm font-medium transition"
>
<GitHubIcon />
Login with GitHub
</a>
<a
href="/oauth2/authorization/facebook"
className="flex items-center gap-3 w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-200 rounded-lg px-4 py-2.5 text-sm font-medium transition"
>
<FacebookIcon />
Login with Facebook
</a>
</div>
{/* Register link */}
<p className="text-center text-sm text-gray-500 mt-6">
Don&apos;t have an account?{" "}
<Link
to={registerHref}
className="text-indigo-400 hover:text-indigo-300 font-medium transition"
>
Register
</Link>
</p>
</div>
</div>
</div>
);
}
function GoogleIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.205c0-.639-.057-1.252-.164-1.841H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
);
}
function GitHubIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/>
</svg>
);
}
function FacebookIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2" xmlns="http://www.w3.org/2000/svg">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.236 2.686.236v2.97h-1.513c-1.491 0-1.956.93-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
</svg>
);
}

View File

@@ -0,0 +1,143 @@
import { useState, FormEvent } from "react";
import { useSearchParams, Link } from "react-router-dom";
function saveTokensAndRedirect(token: string, refreshToken: string, redirect: string) {
localStorage.setItem("token", token);
localStorage.setItem("refreshToken", refreshToken);
window.location.href = redirect;
}
export default function RegisterPage() {
const [searchParams] = useSearchParams();
const redirect = searchParams.get("redirect") ?? "/ragview";
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, email, password }),
});
const data = await res.json();
if (data.success && data.payload?.token) {
saveTokensAndRedirect(data.payload.token, data.payload.refreshToken ?? "", redirect);
} else {
setError(data.message ?? "Registration failed");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
}
const loginHref = `/login?redirect=${encodeURIComponent(redirect)}`;
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Card */}
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-8 shadow-2xl">
<h1 className="text-2xl font-bold text-white mb-2 text-center">Create account</h1>
<p className="text-gray-400 text-sm text-center mb-8">Sign up to get started</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Username
</label>
<input
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="johndoe"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Email
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Password
</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Confirm Password
</label>
<input
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
{error && (
<p className="text-red-400 text-sm bg-red-950/50 border border-red-800 rounded-lg px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg py-2.5 text-sm transition"
>
{loading ? "Creating account…" : "Create account"}
</button>
</form>
{/* Login link */}
<p className="text-center text-sm text-gray-500 mt-6">
Already have an account?{" "}
<Link
to={loginHref}
className="text-indigo-400 hover:text-indigo-300 font-medium transition"
>
Login
</Link>
</p>
</div>
</div>
</div>
);
}

1
auth-view/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
auth-view/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
auth-view/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/auth/",
});