diff --git a/.env b/.env index 37c2814..b94e55f 100644 --- a/.env +++ b/.env @@ -54,4 +54,15 @@ REFRESH_TOKEN_EXPIRATION_SECONDS=604800 # Auth Configuration (structured) AUTH_JWT_SECRET=test-integration-secret-key-do-not-use-in-production AUTH_JWT_EXPIRATION_SECONDS=3600 -AUTH_REFRESH_TOKEN_EXPIRATION_SECONDS=604800 \ No newline at end of file +AUTH_REFRESH_TOKEN_EXPIRATION_SECONDS=604800 + +# Email SMTP Configuration - Gmail (commented for testing) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=buihaigiap0101@gmail.com +SMTP_PASSWORD=ksphdojlraidrbtb +FROM_EMAIL=buihaigiap0101@gmail.com +FROM_NAME="Aerugo Registry" +SMTP_USE_TLS=true +EMAIL_TEST_MODE=false + diff --git a/Cargo.lock b/Cargo.lock index a9950b9..5a4a01d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,9 +45,11 @@ dependencies = [ "hyper-rustls 0.27.7", "indexmap", "jsonwebtoken", + "lettre", "metrics", "metrics-prometheus", "moka", + "rand 0.8.5", "redis", "secrecy", "serde", @@ -924,7 +926,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -947,6 +949,16 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1301,6 +1313,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1776,6 +1804,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.1.3", +] + [[package]] name = "http" version = "0.2.12" @@ -2210,6 +2249,35 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "lettre" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cb54db6ff7a89efac87dba5baeac57bb9ccd726b49a9b6f21fb92b3966aaf56" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna 1.1.0", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls 0.23.31", + "socket2 0.6.0", + "tokio", + "tokio-rustls 0.26.2", + "url", + "webpki-roots", +] + [[package]] name = "libc" version = "0.2.175" @@ -2488,6 +2556,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -2879,6 +2956,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "psm" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" +dependencies = [ + "cc", +] + [[package]] name = "quote" version = "1.0.40" @@ -2888,6 +2974,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -3168,6 +3260,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.4", "subtle", @@ -3583,7 +3676,7 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" dependencies = [ - "nom", + "nom 7.1.3", "unicode_categories", ] @@ -3796,6 +3889,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -4543,6 +4649,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "4.4.2" diff --git a/Cargo.toml b/Cargo.toml index cd9c4a0..3f47634 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,15 @@ validator = { version = "0.16", features = ["derive"] } url = { version = "2.4", features = ["serde"] } envy = "0.4" -sqlx = { version = "0.7", features = ["runtime-tokio", "tls-native-tls", "postgres", "uuid", "chrono"] } +sqlx = { version = "0.7", features = [ + "runtime-tokio", + "tls-native-tls", + "postgres", + "uuid", + "chrono", +] } uuid = { version = "1.6", features = ["serde", "v4"] } +rand = "0.8" argon2 = "0.5" jsonwebtoken = "9.2" thiserror = "1.0" @@ -54,9 +61,22 @@ hyper-rustls = { version = "0.27.7", features = ["http2"] } tokio-stream = "0.1.17" # Performance optimization dependencies -redis = { version = "0.24", features = ["tokio-comp", "connection-manager", "script"] } +redis = { version = "0.24", features = [ + "tokio-comp", + "connection-manager", + "script", +] } moka = { version = "0.12", features = ["future"] } metrics = "0.22" metrics-prometheus = "0.6" bb8-redis = "0.14" indexmap = "2.11.1" + +# Email dependencies +lettre = { version = "0.11", default-features = false, features = [ + "tokio1", + "tokio1-rustls-tls", + "smtp-transport", + "builder", + "hostname", +] } diff --git a/Dockerfile.aerugo b/Dockerfile.aerugo index 94970fd..0b10b0a 100644 --- a/Dockerfile.aerugo +++ b/Dockerfile.aerugo @@ -80,6 +80,26 @@ RUN chown -R aerugo:aerugo /app /usr/local/bin/aerugo USER aerugo EXPOSE 8080 5173 +# Set default environment variables +ENV LISTEN_ADDRESS="0.0.0.0:8080" +ENV LOG_LEVEL="info" +ENV S3_ENDPOINT="http://host.docker.internal:9001" +ENV S3_BUCKET="aerugo-registry" +ENV S3_ACCESS_KEY="minioadmin" +ENV S3_SECRET_KEY="minioadmin" +ENV S3_REGION="us-east-1" +ENV REDIS_URL="redis://host.docker.internal:6379" +ENV JWT_SECRET="your-jwt-secret-change-in-production" +ENV API_PREFIX="/api/v1" +ENV S3_USE_PATH_STYLE="true" +ENV DATABASE_REQUIRE_SSL="false" +ENV DATABASE_MIN_CONNECTIONS="5" +ENV DATABASE_MAX_CONNECTIONS="20" +ENV REDIS_POOL_SIZE="10" +ENV REDIS_TTL_SECONDS="3600" +ENV JWT_EXPIRATION_SECONDS="3600" +ENV REFRESH_TOKEN_EXPIRATION_SECONDS="604800" + # Note: DATABASE_URL should be provided when running the container # Example: docker run -e DATABASE_URL="your_database_url" aerugo:latest @@ -87,4 +107,5 @@ EXPOSE 8080 5173 HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ CMD curl -f http://localhost:8080/docs || exit 1 -CMD ["./start.sh"] +CMD ["/start.sh"] + \ No newline at end of file diff --git a/app/Fe-AI-Decenter b/app/Fe-AI-Decenter deleted file mode 160000 index addde90..0000000 --- a/app/Fe-AI-Decenter +++ /dev/null @@ -1 +0,0 @@ -Subproject commit addde9025ecc02be839af3c0eae117e1f746fbd1 diff --git a/app/Fe-AI-Decenter/.gitignore b/app/Fe-AI-Decenter/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/app/Fe-AI-Decenter/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/app/Fe-AI-Decenter/App.tsx b/app/Fe-AI-Decenter/App.tsx new file mode 100644 index 0000000..009f614 --- /dev/null +++ b/app/Fe-AI-Decenter/App.tsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect } from 'react'; +import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'; +import AuthPage from './pages/AuthPage'; +import DocsPage from './pages/DocsPage'; +import RepositoriesPage from './pages/RepositoriesPage'; +import OrganizationsPage from './pages/OrganizationsPage'; +import ProfilePage from './pages/ProfilePage'; +import DashboardLayout from './components/layout/DashboardLayout'; +import { User } from './types'; +import { fetchCurrentUser } from './services/api'; +import ForgotPasswordPage from './pages/ForgotPasswordPage'; +import ResetPasswordPage from './pages/ResetPasswordPage'; + +const App: React.FC = () => { + const [token, setToken] = useState(localStorage.getItem('authToken')); + const [currentUser, setCurrentUser] = useState(null); + const [isLoadingUser, setIsLoadingUser] = useState(true); + + useEffect(() => { + const validateToken = async () => { + const storedToken = localStorage.getItem('authToken'); + if (storedToken) { + try { + const user = await fetchCurrentUser(storedToken); + setToken(storedToken); + setCurrentUser(user); + } catch (error) { + // Invalid token, clear it + localStorage.removeItem('authToken'); + setToken(null); + setCurrentUser(null); + } finally { + setIsLoadingUser(false); + } + } else { + setIsLoadingUser(false); + } + }; + validateToken(); + }, []); + + const handleLoginSuccess = async (newToken: string) => { + localStorage.setItem('authToken', newToken); + setToken(newToken); + setIsLoadingUser(true); + try { + const user = await fetchCurrentUser(newToken); + setCurrentUser(user); + } catch (error) { + console.error("Failed to fetch current user after login", error); + handleLogout(); // Log out if user fetch fails + } finally { + setIsLoadingUser(false); + } + }; + + const handleLogout = () => { + localStorage.removeItem('authToken'); + setToken(null); + setCurrentUser(null); + // The navigation to the login page will be handled by the protected route logic. + }; + + if (isLoadingUser) { + return ( +
+

Loading...

+
+ ); + } + + return ( + + + + ) : ( + + ) + } + /> + } /> + } /> + } /> + + + {/* Protected Routes */} + + ) : ( + + ) + } + > + } /> + } /> + } /> + {/* FIX: The DocsPage component does not accept a 'token' prop. Removed it to fix the type error. */} + } /> + + + {/* Fallback route */} + } /> + + + ); +}; + +export default App; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/README.md b/app/Fe-AI-Decenter/README.md new file mode 100644 index 0000000..9f94dd7 --- /dev/null +++ b/app/Fe-AI-Decenter/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1kmjxU8muxsy9pbEd7vq2gIssm2rJweo9 + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/app/Fe-AI-Decenter/components/Button.tsx b/app/Fe-AI-Decenter/components/Button.tsx new file mode 100644 index 0000000..dc62b24 --- /dev/null +++ b/app/Fe-AI-Decenter/components/Button.tsx @@ -0,0 +1,67 @@ + +import React from 'react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + isLoading?: boolean; + variant?: 'primary' | 'danger'; + fullWidth?: boolean; +} + +const Button: React.FC = ({ + children, + isLoading = false, + variant = 'primary', + fullWidth = true, + className, + ...props +}) => { + const baseClasses = "flex justify-center items-center px-4 py-2.5 border border-transparent rounded-md font-semibold text-white focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-75 disabled:cursor-not-allowed transition-colors duration-200"; + + const variantClasses = { + primary: "bg-blue-600 hover:bg-blue-700 focus:ring-offset-slate-800 focus:ring-blue-500", + danger: "bg-red-600 hover:bg-red-700 focus:ring-offset-slate-800 focus:ring-red-500", + }; + + const widthClass = fullWidth ? "w-full" : "w-auto"; + + const finalClassName = [ + baseClasses, + variantClasses[variant], + widthClass, + className, + ].filter(Boolean).join(' '); + + return ( + + ); +}; + +export default Button; diff --git a/app/Fe-AI-Decenter/components/Input.tsx b/app/Fe-AI-Decenter/components/Input.tsx new file mode 100644 index 0000000..fffd5e7 --- /dev/null +++ b/app/Fe-AI-Decenter/components/Input.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +interface InputProps extends React.InputHTMLAttributes { + label: string; + id: string; +} + +const Input: React.FC = ({ label, id, ...props }) => { + return ( +
+ + +
+ ); +}; + +export default Input; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/LoginForm.tsx b/app/Fe-AI-Decenter/components/LoginForm.tsx new file mode 100644 index 0000000..98eb725 --- /dev/null +++ b/app/Fe-AI-Decenter/components/LoginForm.tsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import Input from './Input'; +import Button from './Button'; +import { API_BASE_URL } from '../config'; + +interface LoginFormProps { + onLoginSuccess: (token: string) => void; +} + +const LoginForm: React.FC = ({ onLoginSuccess }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!email || !password) { + setError('Please fill in all fields.'); + return; + } + + setIsLoading(true); + + try { + const response = await fetch(`${API_BASE_URL}/api/v1/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + if (response.ok) { + const data = await response.json(); + onLoginSuccess(data.token); + navigate('/repositories', { replace: true }); + } else if (response.status === 401) { + setError('Invalid email or password.'); + } else { + setError('An unexpected error occurred. Please try again.'); + } + } catch (err) { + setError('Failed to connect to the server. Please check your network.'); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ setEmail(e.target.value)} + placeholder="you@example.com" + disabled={isLoading} + autoComplete="email" + /> +
+ setPassword(e.target.value)} + placeholder="••••••••" + disabled={isLoading} + autoComplete="current-password" + /> +
+ + Forgot your password? + +
+
+ {error &&

{error}

} + +
+ ); +}; + +export default LoginForm; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/Modal.tsx b/app/Fe-AI-Decenter/components/Modal.tsx new file mode 100644 index 0000000..be4255c --- /dev/null +++ b/app/Fe-AI-Decenter/components/Modal.tsx @@ -0,0 +1,45 @@ +import React, { useEffect } from 'react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +const Modal: React.FC = ({ isOpen, onClose, title, children }) => { + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', handleEsc); + return () => { + window.removeEventListener('keydown', handleEsc); + }; + }, [onClose]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ {children} +
+
+
+ ); +}; + +export default Modal; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/RegisterForm.tsx b/app/Fe-AI-Decenter/components/RegisterForm.tsx new file mode 100644 index 0000000..23d222f --- /dev/null +++ b/app/Fe-AI-Decenter/components/RegisterForm.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import Input from './Input'; +import Button from './Button'; +import { API_BASE_URL } from '../config'; + +interface RegisterFormProps { + onRegisterSuccess: () => void; +} + +const RegisterForm: React.FC = ({ onRegisterSuccess }) => { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [success, setSuccess] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + + if (!username || !email || !password || !confirmPassword) { + setError('Please fill in all fields.'); + return; + } + + if (password !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters long.'); + return; + } + + setIsLoading(true); + + try { + const response = await fetch(`${API_BASE_URL}/api/v1/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, email, password }), + }); + + if (response.status === 201) { + setSuccess('Registration successful! Please sign in.'); + setTimeout(() => { + onRegisterSuccess(); + }, 1500); + } else if (response.status === 409) { + setError('A user with that username or email already exists.'); + } else { + setError('An unexpected error occurred. Please try again.'); + } + } catch (err) { + setError('Failed to connect to the server. Please check your network.'); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ setUsername(e.target.value)} + placeholder="yourusername" + disabled={isLoading} + autoComplete="username" + /> + setEmail(e.target.value)} + placeholder="you@example.com" + disabled={isLoading} + autoComplete="email" + /> + setPassword(e.target.value)} + placeholder="••••••••" + disabled={isLoading} + autoComplete="new-password" + /> + setConfirmPassword(e.target.value)} + placeholder="••••••••" + disabled={isLoading} + autoComplete="new-password" + /> + {error &&

{error}

} + {success &&

{success}

} + +
+ ); +}; + +export default RegisterForm; diff --git a/app/Fe-AI-Decenter/components/docs/ArchitectureDiagram.tsx b/app/Fe-AI-Decenter/components/docs/ArchitectureDiagram.tsx new file mode 100644 index 0000000..2b64adb --- /dev/null +++ b/app/Fe-AI-Decenter/components/docs/ArchitectureDiagram.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { ArrowDownIcon } from './ArrowDownIcon'; + +const DiagramBox: React.FC<{ title: string; children?: React.ReactNode; className?: string }> = ({ title, children, className }) => ( +
+

{title}

+ {children &&

{children}

} +
+); + +const ArchitectureDiagram: React.FC = () => { + return ( +
+ + + + + + {/* Nodes tier */} +
+ + + +
+ + + + {/* Backend Services Tier */} +
+
+ + + +
+

All nodes connect to these backend services

+
+
+ ); +}; + +export default ArchitectureDiagram; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/docs/ArrowDownIcon.tsx b/app/Fe-AI-Decenter/components/docs/ArrowDownIcon.tsx new file mode 100644 index 0000000..a02ebad --- /dev/null +++ b/app/Fe-AI-Decenter/components/docs/ArrowDownIcon.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +export const ArrowDownIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/docs/CodeBlock.tsx b/app/Fe-AI-Decenter/components/docs/CodeBlock.tsx new file mode 100644 index 0000000..4c6d236 --- /dev/null +++ b/app/Fe-AI-Decenter/components/docs/CodeBlock.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import { ClipboardIcon } from '../icons/ClipboardIcon'; + +interface CodeBlockProps { + code: string; + language?: string; +} + +const CodeBlock: React.FC = ({ code, language }) => { + const [copyStatus, setCopyStatus] = useState('Copy'); + + const handleCopy = () => { + navigator.clipboard.writeText(code).then(() => { + setCopyStatus('Copied!'); + setTimeout(() => setCopyStatus('Copy'), 2000); + }).catch(err => { + console.error('Failed to copy text: ', err); + setCopyStatus('Failed'); + setTimeout(() => setCopyStatus('Copy'), 2000); + }); + }; + + return ( +
+
+        {code}
+      
+ +
+ ); +}; + +export default CodeBlock; diff --git a/app/Fe-AI-Decenter/components/icons/ArrowLeftIcon.tsx b/app/Fe-AI-Decenter/components/icons/ArrowLeftIcon.tsx new file mode 100644 index 0000000..ee770bc --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/ArrowLeftIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const ArrowLeftIcon: React.FC> = (props) => ( + + + +); \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/icons/BriefcaseIcon.tsx b/app/Fe-AI-Decenter/components/icons/BriefcaseIcon.tsx new file mode 100644 index 0000000..904dd9d --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/BriefcaseIcon.tsx @@ -0,0 +1,24 @@ + +import React from 'react'; + +export const BriefcaseIcon: React.FC> = (props) => ( + + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/ChartBarIcon.tsx b/app/Fe-AI-Decenter/components/icons/ChartBarIcon.tsx new file mode 100644 index 0000000..dccf6ec --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/ChartBarIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const ChartBarIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/ClipboardIcon.tsx b/app/Fe-AI-Decenter/components/icons/ClipboardIcon.tsx new file mode 100644 index 0000000..c0c3c33 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/ClipboardIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const ClipboardIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/ClockIcon.tsx b/app/Fe-AI-Decenter/components/icons/ClockIcon.tsx new file mode 100644 index 0000000..25565af --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/ClockIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const ClockIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/CloudIcon.tsx b/app/Fe-AI-Decenter/components/icons/CloudIcon.tsx new file mode 100644 index 0000000..4065d72 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/CloudIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const CloudIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/CodeBracketIcon.tsx b/app/Fe-AI-Decenter/components/icons/CodeBracketIcon.tsx new file mode 100644 index 0000000..674e3cb --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/CodeBracketIcon.tsx @@ -0,0 +1,19 @@ + +import React from 'react'; + +export const CodeBracketIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/CogIcon.tsx b/app/Fe-AI-Decenter/components/icons/CogIcon.tsx new file mode 100644 index 0000000..5340414 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/CogIcon.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +export const CogIcon: React.FC> = (props) => ( + + + + +); \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/icons/CubeIcon.tsx b/app/Fe-AI-Decenter/components/icons/CubeIcon.tsx new file mode 100644 index 0000000..2f24b29 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/CubeIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const CubeIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/DockerIcon.tsx b/app/Fe-AI-Decenter/components/icons/DockerIcon.tsx new file mode 100644 index 0000000..112eb24 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/DockerIcon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +export const AerugoIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/GlobeIcon.tsx b/app/Fe-AI-Decenter/components/icons/GlobeIcon.tsx new file mode 100644 index 0000000..242f3cc --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/GlobeIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const GlobeIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/LockIcon.tsx b/app/Fe-AI-Decenter/components/icons/LockIcon.tsx new file mode 100644 index 0000000..9801ae4 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/LockIcon.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export const LockIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/LogoutIcon.tsx b/app/Fe-AI-Decenter/components/icons/LogoutIcon.tsx new file mode 100644 index 0000000..8501580 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/LogoutIcon.tsx @@ -0,0 +1,19 @@ + +import React from 'react'; + +export const LogoutIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/PlusIcon.tsx b/app/Fe-AI-Decenter/components/icons/PlusIcon.tsx new file mode 100644 index 0000000..9e5e9b0 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/PlusIcon.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export const PlusIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/RocketLaunchIcon.tsx b/app/Fe-AI-Decenter/components/icons/RocketLaunchIcon.tsx new file mode 100644 index 0000000..01d8810 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/RocketLaunchIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const RocketLaunchIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/RssIcon.tsx b/app/Fe-AI-Decenter/components/icons/RssIcon.tsx new file mode 100644 index 0000000..a4dc822 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/RssIcon.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +export const RssIcon: React.FC> = (props) => ( + + + + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/SearchIcon.tsx b/app/Fe-AI-Decenter/components/icons/SearchIcon.tsx new file mode 100644 index 0000000..eacda0d --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/SearchIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const SearchIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/ServerStackIcon.tsx b/app/Fe-AI-Decenter/components/icons/ServerStackIcon.tsx new file mode 100644 index 0000000..b3cedff --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/ServerStackIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const ServerStackIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/ShieldCheckIcon.tsx b/app/Fe-AI-Decenter/components/icons/ShieldCheckIcon.tsx new file mode 100644 index 0000000..c189634 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/ShieldCheckIcon.tsx @@ -0,0 +1,19 @@ + +import React from 'react'; + +export const ShieldCheckIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/TagIcon.tsx b/app/Fe-AI-Decenter/components/icons/TagIcon.tsx new file mode 100644 index 0000000..69449cc --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/TagIcon.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +export const TagIcon: React.FC> = (props) => ( + + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/TrashIcon.tsx b/app/Fe-AI-Decenter/components/icons/TrashIcon.tsx new file mode 100644 index 0000000..460037b --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/TrashIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const TrashIcon: React.FC> = (props) => ( + + + +); \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/icons/UserCircleIcon.tsx b/app/Fe-AI-Decenter/components/icons/UserCircleIcon.tsx new file mode 100644 index 0000000..217a592 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/UserCircleIcon.tsx @@ -0,0 +1,19 @@ + +import React from 'react'; + +export const UserCircleIcon: React.FC> = (props) => ( + + + +); diff --git a/app/Fe-AI-Decenter/components/icons/UsersIcon.tsx b/app/Fe-AI-Decenter/components/icons/UsersIcon.tsx new file mode 100644 index 0000000..ba043c0 --- /dev/null +++ b/app/Fe-AI-Decenter/components/icons/UsersIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export const UsersIcon: React.FC> = (props) => ( + + + +); \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/layout/DashboardLayout.tsx b/app/Fe-AI-Decenter/components/layout/DashboardLayout.tsx new file mode 100644 index 0000000..88403e8 --- /dev/null +++ b/app/Fe-AI-Decenter/components/layout/DashboardLayout.tsx @@ -0,0 +1,108 @@ + +import React, { useState, useEffect, useRef } from 'react'; +import { Outlet, NavLink, Link } from 'react-router-dom'; +import { User } from '../../types'; +import { AerugoIcon } from '../icons/DockerIcon'; +import { UserCircleIcon } from '../icons/UserCircleIcon'; +import { LogoutIcon } from '../icons/LogoutIcon'; + +interface DashboardLayoutProps { + currentUser: User; + onLogout: () => void; +} + +const DashboardLayout: React.FC = ({ currentUser, onLogout }) => { + const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); + const menuRef = useRef(null); + + const navLinkClasses = ({ isActive }: { isActive: boolean }) => + `px-3 py-2 rounded-md text-sm font-medium transition-colors ${ + isActive + ? 'bg-slate-700 text-white' + : 'text-slate-300 hover:bg-slate-700/50 hover:text-white' + }`; + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsProfileMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [menuRef]); + + return ( +
+
+ +
+
+ +
+
+ ); +}; + +export default DashboardLayout; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/organization/AddMemberForm.tsx b/app/Fe-AI-Decenter/components/organization/AddMemberForm.tsx new file mode 100644 index 0000000..5bcae55 --- /dev/null +++ b/app/Fe-AI-Decenter/components/organization/AddMemberForm.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import { addOrganizationMember } from '../../services/api'; +import { OrganizationRole, AddMemberRequest } from '../../types'; +import Input from '../Input'; +import Button from '../Button'; + +interface AddMemberFormProps { + token: string; + orgId: number; + onSuccess: () => void; + onCancel: () => void; +} + +const AddMemberForm: React.FC = ({ token, orgId, onSuccess, onCancel }) => { + const [email, setEmail] = useState(''); + const [role, setRole] = useState(OrganizationRole.Member); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) { + setError('Email address is required.'); + return; + } + + setIsLoading(true); + setError(null); + try { + const data: AddMemberRequest = { email, role }; + await addOrganizationMember(orgId, data, token); + onSuccess(); + } catch (err: any) { + if (err.status === 400) { + setError('User with that email not found.'); + } else if (err.status === 409) { + setError('User is already a member of this organization.'); + } else { + setError('Failed to add member. Please try again.'); + } + console.error(err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Add New Member

+
+ setEmail(e.target.value)} + placeholder="user@example.com" + required + /> + +
+ + +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +}; + +export default AddMemberForm; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/organization/CreateOrganizationForm.tsx b/app/Fe-AI-Decenter/components/organization/CreateOrganizationForm.tsx new file mode 100644 index 0000000..44e943b --- /dev/null +++ b/app/Fe-AI-Decenter/components/organization/CreateOrganizationForm.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { createOrganization } from '../../services/api'; +import { CreateOrganizationRequest } from '../../types'; +import Input from '../Input'; +import Button from '../Button'; + +interface CreateOrganizationFormProps { + token: string; + onSuccess: () => void; + onCancel: () => void; +} + +const CreateOrganizationForm: React.FC = ({ token, onSuccess, onCancel }) => { + const [formData, setFormData] = useState({ + name: '', + display_name: '', + description: '', + avatar_url: '', + website_url: '', + }); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.name || !formData.display_name) { + setError('Organization name and display name are required.'); + return; + } + // Basic validation for org name (URL-friendly) + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(formData.name)) { + setError('Name must be lowercase, alphanumeric, and can contain hyphens.'); + return; + } + + setIsLoading(true); + setError(null); + try { + const payload: CreateOrganizationRequest = { + ...formData, + avatar_url: formData.avatar_url || undefined, + website_url: formData.website_url || undefined, + }; + await createOrganization(payload, token); + onSuccess(); + } catch (err) { + setError('Failed to create organization. Please try again.'); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Create New Organization

+
+ + +

Lowercase, numbers, and hyphens only.

+ + + + + + {error &&

{error}

} + +
+ + +
+
+
+ ); +}; + +export default CreateOrganizationForm; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/organization/MemberList.tsx b/app/Fe-AI-Decenter/components/organization/MemberList.tsx new file mode 100644 index 0000000..d7fe71e --- /dev/null +++ b/app/Fe-AI-Decenter/components/organization/MemberList.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { OrganizationMember, User } from '../../types'; +import MemberListItem from './MemberListItem'; +import { UsersIcon } from '../icons/UsersIcon'; + +interface MemberListProps { + members: OrganizationMember[]; + currentUser: User; + currentUserRole?: string; + orgId: number; + token: string; + onDataChange: () => void; +} + +const MemberList: React.FC = ({ members, currentUser, currentUserRole, orgId, token, onDataChange }) => { + if (members.length === 0) { + return ( +
+ +

No Members Found

+

This organization doesn't have any members yet.

+
+ ); + } + + // Define the hierarchical order of roles with lowercase keys for robust sorting + const roleOrder: { [key: string]: number } = { + 'owner': 1, + 'admin': 2, + 'member': 3, + }; + + // Sort members based on the defined role order + const sortedMembers = [...members].sort((a, b) => { + const roleA = a.role.toLowerCase(); + const roleB = b.role.toLowerCase(); + const orderA = roleOrder[roleA] || 99; // Fallback for any unknown roles + const orderB = roleOrder[roleB] || 99; + + if (orderA !== orderB) { + return orderA - orderB; + } + // If roles are the same, sort by username + return a.username.localeCompare(b.username); + }); + + const ownerCount = members.filter(m => m.role.toLowerCase() === 'owner').length; + + return ( +
+
    + {sortedMembers.map(member => ( + + ))} +
+
+ ); +}; + +export default MemberList; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/organization/MemberListItem.tsx b/app/Fe-AI-Decenter/components/organization/MemberListItem.tsx new file mode 100644 index 0000000..34e74f6 --- /dev/null +++ b/app/Fe-AI-Decenter/components/organization/MemberListItem.tsx @@ -0,0 +1,140 @@ +import React, { useState } from 'react'; +import { OrganizationMember, User, OrganizationRole } from '../../types'; +import { updateMemberRole, deleteMember } from '../../services/api'; +import Button from '../Button'; +import Modal from '../Modal'; +import { TrashIcon } from '../icons/TrashIcon'; + +interface MemberListItemProps { + member: OrganizationMember; + isLastOwner: boolean; + currentUser: User; + currentUserRole?: string; + orgId: number; + token: string; + onDataChange: () => void; +} + +const MemberListItem: React.FC = ({ member, isLastOwner, currentUser, currentUserRole, orgId, token, onDataChange }) => { + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const handleRoleChange = async (e: React.ChangeEvent) => { + const newRoleValue = e.target.value; + const capitalizedRole = (newRoleValue.charAt(0).toUpperCase() + newRoleValue.slice(1)) as OrganizationRole; + + setIsUpdating(true); + setError(null); + try { + // The API requires the user's ID for updates, not the membership ID. + await updateMemberRole(orgId, member.user_id, capitalizedRole, token); + onDataChange(); + } catch (err) { + console.error(err); + setError("Failed to update role."); + } finally { + setIsUpdating(false); + } + }; + + const handleConfirmDelete = async () => { + setIsDeleting(true); + setError(null); + try { + // The API for deleting a member requires the user's ID, not the membership ID. + await deleteMember(orgId, member.user_id, token); + setIsDeleteModalOpen(false); + onDataChange(); + } catch (err) { + console.error(err); + setError("Failed to remove member."); + } finally { + setIsDeleting(false); + } + }; + + const roleColors: { [key: string]: string } = { + owner: 'bg-purple-900/70 text-purple-300', + admin: 'bg-indigo-900/70 text-indigo-300', + member: 'bg-slate-600/70 text-slate-300', + }; + + const roleClass = roleColors[member.role.toLowerCase()] || roleColors.member; + const displayRole = member.role.charAt(0).toUpperCase() + member.role.slice(1); + + const canManage = currentUserRole === 'owner' || (currentUserRole === 'admin' && member.role.toLowerCase() !== 'owner'); + const isSelf = currentUser.id === member.user_id; + + return ( + <> +
  • +
    +

    {member.username}

    +

    {member.email}

    + {error && !isDeleteModalOpen &&

    {error}

    } +
    +
    + {canManage && !isSelf ? ( + + ) : ( + + {displayRole} + + )} + + {canManage && !isSelf && !isLastOwner && ( + + )} +
    +
  • + + setIsDeleteModalOpen(false)} + title="Remove Member" + > +
    +

    + Are you sure you want to remove {member.username} from the organization? +

    + {error &&

    {error}

    } +
    + + +
    +
    +
    + + ); +}; + +export default MemberListItem; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/organization/OrganizationDetail.tsx b/app/Fe-AI-Decenter/components/organization/OrganizationDetail.tsx new file mode 100644 index 0000000..e16b194 --- /dev/null +++ b/app/Fe-AI-Decenter/components/organization/OrganizationDetail.tsx @@ -0,0 +1,183 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Organization, OrganizationMember, User } from '../../types'; +import { fetchOrganizationMembers, fetchOrganizationDetails } from '../../services/api'; +import MemberList from './MemberList'; +import Button from '../Button'; +import { PlusIcon } from '../icons/PlusIcon'; +import AddMemberForm from './AddMemberForm'; +import OrganizationSettings from './OrganizationSettings'; +import { UsersIcon } from '../icons/UsersIcon'; +import { CogIcon } from '../icons/CogIcon'; + +interface OrganizationDetailProps { + token: string; + currentUser: User; + organization: Organization; + onDataChange: () => void; +} + +type Tab = 'members' | 'settings'; + +const OrganizationDetail: React.FC = ({ token, currentUser, organization, onDataChange }) => { + const [activeTab, setActiveTab] = useState('members'); + const [detailedOrg, setDetailedOrg] = useState(organization); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const getDetails = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await fetchOrganizationDetails(organization.id, token); + setDetailedOrg(data); + } catch (err) { + setError('Failed to load up-to-date organization details.'); + console.error(err); + } finally { + setIsLoading(false); + } + }, [organization.id, token]); + + useEffect(() => { + getDetails(); + }, [getDetails]); + + const handleSettingsChange = () => { + getDetails(); // Re-fetch my own details + onDataChange(); // Tell parent to re-fetch the list + } + + return ( +
    +
    +

    {detailedOrg.display_name}

    +

    @{detailedOrg.name}

    +
    + +
    + +
    + +
    + {isLoading ? ( +
    Loading details...
    + ) : error ? ( +
    {error}
    + ) : ( + <> + {activeTab === 'members' && ( + + )} + {activeTab === 'settings' && ( + + )} + + )} +
    +
    + ); +}; + +// Internal tab button component for styling +const TabButton: React.FC<{icon: React.ReactNode, label: string, isActive: boolean, onClick: () => void}> = ({ icon, label, isActive, onClick }) => ( + +); + +// Extracted Members view logic into its own component for cleanliness +const MembersView: React.FC<{token: string, organization: Organization, currentUser: User}> = ({ token, organization, currentUser }) => { + const [members, setMembers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + const getMembers = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + const memberList = await fetchOrganizationMembers(organization.id, token); + setMembers(memberList); + } catch (err) { + setError('Failed to load members.'); + console.error(err); + } finally { + setIsLoading(false); + } + }, [organization.id, token]); + + useEffect(() => { + getMembers(); + }, [getMembers]); + + const handleSuccess = () => { + setShowAddForm(false); + getMembers(); // Refresh member list + } + + const currentUserRole = members.find(m => m.user_id === currentUser.id)?.role.toLowerCase(); + + return ( +
    +
    +

    Members

    + {!showAddForm && (currentUserRole === 'owner' || currentUserRole === 'admin') && ( + + )} +
    + {showAddForm && ( + setShowAddForm(false)} + /> + )} + {isLoading ? ( +
    Loading members...
    + ) : error ? ( +
    {error}
    + ) : ( + + )} +
    + ); +}; + +export default OrganizationDetail; diff --git a/app/Fe-AI-Decenter/components/organization/OrganizationList.tsx b/app/Fe-AI-Decenter/components/organization/OrganizationList.tsx new file mode 100644 index 0000000..472fcd0 --- /dev/null +++ b/app/Fe-AI-Decenter/components/organization/OrganizationList.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Organization } from '../../types'; +import OrganizationListItem from './OrganizationListItem'; + +interface OrganizationListProps { + organizations: Organization[]; + selectedOrganizationId: number | null; + onSelectOrganization: (organization: Organization) => void; +} + +const OrganizationList: React.FC = ({ organizations, selectedOrganizationId, onSelectOrganization }) => { + if (organizations.length === 0) { + return ( +
    +

    No Organizations Found

    +

    Get started by creating a new organization.

    +
    + ); + } + + return ( +
    +
      + {organizations.map(org => ( + onSelectOrganization(org)} + /> + ))} +
    +
    + ); +}; + +export default OrganizationList; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/organization/OrganizationListItem.tsx b/app/Fe-AI-Decenter/components/organization/OrganizationListItem.tsx new file mode 100644 index 0000000..c1ff0ee --- /dev/null +++ b/app/Fe-AI-Decenter/components/organization/OrganizationListItem.tsx @@ -0,0 +1,48 @@ + +import React from 'react'; +import { Organization } from '../../types'; +import { AerugoIcon } from '../icons/DockerIcon'; + +interface OrganizationListItemProps { + organization: Organization; + isSelected: boolean; + onSelect: () => void; +} + +const OrganizationListItem: React.FC = ({ organization, isSelected, onSelect }) => { + const baseClasses = "w-full text-left p-4 sm:p-6 transition-colors duration-200 focus:outline-none"; + const selectedClasses = "bg-blue-900/50"; + const hoverClasses = "hover:bg-slate-700/50"; + + return ( +
  • + +
  • + ); +}; + +export default OrganizationListItem; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/organization/OrganizationSelector.tsx b/app/Fe-AI-Decenter/components/organization/OrganizationSelector.tsx new file mode 100644 index 0000000..9490360 --- /dev/null +++ b/app/Fe-AI-Decenter/components/organization/OrganizationSelector.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Organization } from '../../types'; + +interface OrganizationSelectorProps { + organizations: Organization[]; + isLoading: boolean; + error: string | null; + selectedOrganizationId: number | null; + onOrganizationSelect: (orgId: number | null) => void; +} + +const OrganizationSelector: React.FC = ({ + organizations, + isLoading, + error, + selectedOrganizationId, + onOrganizationSelect +}) => { + + const handleChange = (event: React.ChangeEvent) => { + const value = event.target.value; + // When "All Organizations" is selected, the value is an empty string. + // `Number('')` is 0, so we check for this case to pass `null`. + const orgId = value ? Number(value) : null; + onOrganizationSelect(orgId); + }; + + const value = selectedOrganizationId ?? ""; + + if (isLoading) { + return ( + + ); + } + + if (error) { + return ( + + ); + } + + return ( + + ); +}; + +export default OrganizationSelector; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/organization/OrganizationSettings.tsx b/app/Fe-AI-Decenter/components/organization/OrganizationSettings.tsx new file mode 100644 index 0000000..31c1166 --- /dev/null +++ b/app/Fe-AI-Decenter/components/organization/OrganizationSettings.tsx @@ -0,0 +1,201 @@ +import React, { useState } from 'react'; +import { updateOrganization, deleteOrganization } from '../../services/api'; +import { Organization, UpdateOrganizationRequest } from '../../types'; +import Input from '../Input'; +import Button from '../Button'; +import Modal from '../Modal'; +import { TrashIcon } from '../icons/TrashIcon'; + +interface OrganizationSettingsProps { + token: string; + organization: Organization; + onOrganizationUpdated: () => void; + onOrganizationDeleted: () => void; +} + +const OrganizationSettings: React.FC = ({ + token, + organization, + onOrganizationUpdated, + onOrganizationDeleted +}) => { + const [formData, setFormData] = useState({ + display_name: organization.display_name, + description: organization.description || '', + avatar_url: organization.avatar_url || '', + website_url: organization.website_url || '', + }); + const [isUpdating, setIsUpdating] = useState(false); + const [updateError, setUpdateError] = useState(null); + const [updateSuccess, setUpdateSuccess] = useState(null); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [deleteConfirmationName, setDeleteConfirmationName] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleUpdateSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.display_name) { + setUpdateError('Display name is required.'); + return; + } + + setIsUpdating(true); + setUpdateError(null); + setUpdateSuccess(null); + try { + const payload: UpdateOrganizationRequest = { + ...formData, + avatar_url: formData.avatar_url || undefined, + website_url: formData.website_url || undefined, + }; + await updateOrganization(organization.id, payload, token); + setUpdateSuccess('Organization updated successfully!'); + onOrganizationUpdated(); + setTimeout(() => setUpdateSuccess(null), 3000); + } catch (err) { + setUpdateError('Failed to update organization. Please try again.'); + console.error(err); + } finally { + setIsUpdating(false); + } + }; + + const handleDelete = async () => { + if (deleteConfirmationName !== organization.name) { + setDeleteError(`Type "${organization.name}" to confirm.`); + return; + } + setIsDeleting(true); + setDeleteError(null); + try { + await deleteOrganization(organization.id, token); + setIsDeleteModalOpen(false); + onOrganizationDeleted(); + } catch (err) { + setDeleteError('Failed to delete organization. Please try again.'); + console.error(err); + } finally { + setIsDeleting(false); + } + }; + + return ( +
    +
    +

    General Settings

    +
    +
    + +
    +
    + +

    {organization.name}

    +
    +
    + +
    +
    + +
    +
    + +
    + + {updateError &&

    {updateError}

    } + {updateSuccess &&

    {updateSuccess}

    } + +
    + +
    +
    +
    + +
    +

    Danger Zone

    +

    Deleting an organization is permanent and cannot be undone. This will also delete all associated repositories and images.

    + +
    + + setIsDeleteModalOpen(false)} + title="Delete Organization" + > +
    +

    + Are you sure you want to delete {organization.display_name}? + This action is irreversible. +

    +

    To confirm, please type {organization.name} in the box below.

    + setDeleteConfirmationName(e.target.value)} + placeholder={organization.name} + /> + {deleteError &&

    {deleteError}

    } +
    + + +
    +
    +
    + +
    + ); +}; + +export default OrganizationSettings; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/organization/OrganizationsManager.tsx b/app/Fe-AI-Decenter/components/organization/OrganizationsManager.tsx new file mode 100644 index 0000000..d40b795 --- /dev/null +++ b/app/Fe-AI-Decenter/components/organization/OrganizationsManager.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { Organization, User } from '../../types'; +import OrganizationList from './OrganizationList'; +import CreateOrganizationForm from './CreateOrganizationForm'; +import Button from '../Button'; +import { PlusIcon } from '../icons/PlusIcon'; +import OrganizationDetail from './OrganizationDetail'; + +interface OrganizationsManagerProps { + token: string; + currentUser: User; + organizations: Organization[]; + isLoading: boolean; + error: string | null; + onDataChange: () => void; +} + +const OrganizationsManager: React.FC = ({ + token, + currentUser, + organizations, + isLoading, + error, + onDataChange, +}) => { + const [showCreateForm, setShowCreateForm] = useState(false); + const [selectedOrganization, setSelectedOrganization] = useState(null); + + const handleCreationSuccess = () => { + setShowCreateForm(false); + onDataChange(); + } + + const handleDataChange = () => { + setSelectedOrganization(null); + onDataChange(); + }; + + const handleSelectOrganization = (org: Organization) => { + setSelectedOrganization(org); + setShowCreateForm(false); // Hide create form when selecting an org + }; + + return ( +
    +
    +
    +

    Organizations

    + {!showCreateForm && ( + + )} +
    + + {isLoading &&
    Loading organizations...
    } + {error &&
    {error}
    } + {!isLoading && !error && ( + + )} +
    +
    + {showCreateForm && ( + setShowCreateForm(false)} + /> + )} + + {!showCreateForm && selectedOrganization && ( + + )} + + {!showCreateForm && !selectedOrganization && ( +
    +
    +

    Manage Your Organization

    +

    Select an organization to manage its members and settings, or create a new one to get started.

    +
    +
    + )} +
    +
    + ); +}; + +export default OrganizationsManager; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/repository/CommandSnippet.tsx b/app/Fe-AI-Decenter/components/repository/CommandSnippet.tsx new file mode 100644 index 0000000..726c929 --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/CommandSnippet.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; +import { ClipboardIcon } from '../icons/ClipboardIcon'; + +interface CommandSnippetProps { + command: string; +} + +const CommandSnippet: React.FC = ({ command }) => { + const [copyStatus, setCopyStatus] = useState('Copy'); + + const handleCopy = () => { + navigator.clipboard.writeText(command).then(() => { + setCopyStatus('Copied!'); + setTimeout(() => setCopyStatus('Copy'), 2000); + }).catch(err => { + console.error('Failed to copy text: ', err); + setCopyStatus('Failed'); + setTimeout(() => setCopyStatus('Copy'), 2000); + }); + }; + + return ( +
    +
    +        {command}
    +      
    + +
    + ); +}; + +export default CommandSnippet; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/repository/CreateRepositoryForm.tsx b/app/Fe-AI-Decenter/components/repository/CreateRepositoryForm.tsx new file mode 100644 index 0000000..e45100e --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/CreateRepositoryForm.tsx @@ -0,0 +1,162 @@ +import React, { useState } from 'react'; +import { createRepository } from '../../services/api'; +import { CreateRepositoryRequest, Repository } from '../../types'; +import Input from '../Input'; +import Button from '../Button'; +import { GlobeIcon } from '../icons/GlobeIcon'; +import { LockIcon } from '../icons/LockIcon'; + +interface CreateRepositoryFormProps { + token: string; + organizationName: string; + onSuccess: (newRepo: Repository) => void; + onCancel: () => void; +} + +// Define an interface for the form's internal state for better type safety +interface RepositoryFormState { + name: string; + description: string; + visibility: 'public' | 'private'; +} + +const CreateRepositoryForm: React.FC = ({ token, organizationName, onSuccess, onCancel }) => { + const [formData, setFormData] = useState({ + name: '', + description: '', + visibility: 'private', + }); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleVisibilityChange = (visibility: 'public' | 'private') => { + setFormData(prev => ({ ...prev, visibility })); + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!formData.name) { + setError('Repository name is required.'); + return; + } + + // Basic validation for repo name (URL-friendly) + if (!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(formData.name)) { + setError('Name must be lowercase, alphanumeric, and can contain separators (-, _, .). It cannot start or end with a separator.'); + return; + } + + setIsLoading(true); + try { + // Transform the form state into the required API payload + const apiPayload: CreateRepositoryRequest = { + name: formData.name, + description: formData.description || null, // Send null if description is empty + is_public: formData.visibility === 'public', + }; + + const newRepo = await createRepository(organizationName, apiPayload, token); + onSuccess(newRepo); + } catch (err) { + setError('Failed to create repository. The name might already be taken.'); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + return ( +
    +

    Create New Repository

    +
    +
    + +

    Lowercase, numbers, and separators (-, _, .) only.

    +
    + + + +
    + +
    + } + label="Public" + description="Anyone can see this repository." + isSelected={formData.visibility === 'public'} + onSelect={() => handleVisibilityChange('public')} + /> + } + label="Private" + description="You choose who can see this repository." + isSelected={formData.visibility === 'private'} + onSelect={() => handleVisibilityChange('private')} + /> +
    +
    + + + {error &&

    {error}

    } + +
    + + +
    +
    +
    + ); +}; + + +interface VisibilityOptionProps { + id: string; + icon: React.ReactNode; + label: string; + description: string; + isSelected: boolean; + onSelect: () => void; +} + +const VisibilityOption: React.FC = ({ id, icon, label, description, isSelected, onSelect }) => { + return ( + + ); +} + +export default CreateRepositoryForm; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/repository/RepositoryBrowser.tsx b/app/Fe-AI-Decenter/components/repository/RepositoryBrowser.tsx new file mode 100644 index 0000000..c34ee75 --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/RepositoryBrowser.tsx @@ -0,0 +1,194 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { fetchRepositories, fetchRepositoriesByNamespace } from '../../services/api'; +import { Repository } from '../../types'; +import RepositoryList from './RepositoryList'; +import CreateRepositoryForm from './CreateRepositoryForm'; +import RepositoryDetail from './RepositoryDetail'; +import { SearchIcon } from '../icons/SearchIcon'; +import Button from '../Button'; +import { PlusIcon } from '../icons/PlusIcon'; + +interface RepositoryBrowserProps { + token: string; + organizationName?: string; +} + +const RepositoryBrowser: React.FC = ({ token, organizationName }) => { + // State for the repositories shown in the "My Repositories" section. + // This is context-dependent (all repos vs. repos in a specific org). + const [contextRepositories, setContextRepositories] = useState([]); + + // State for ALL public repositories, used for the "Community Repositories" section. + // This is only populated when viewing "All Organizations". + const [allPublicRepositories, setAllPublicRepositories] = useState([]); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [showCreateForm, setShowCreateForm] = useState(false); + const [viewingRepository, setViewingRepository] = useState(null); + + useEffect(() => { + // Reset views when the organization context changes + setSearchTerm(''); + setShowCreateForm(false); + setViewingRepository(null); + + const getRepositories = async () => { + setIsLoading(true); + setError(null); + try { + if (organizationName) { + // A specific organization is selected. Fetch ONLY its repositories. + const orgRepos = await fetchRepositoriesByNamespace(organizationName, token); + setContextRepositories(Array.isArray(orgRepos) ? orgRepos : []); + // Community repos are not shown in this view, so clear the list. + setAllPublicRepositories([]); + } else { + // "All Organizations" is selected. Fetch everything to show both "My Repos" and "Community Repos". + const allRepos = await fetchRepositories(token); + const reposArray = Array.isArray(allRepos) ? allRepos : []; + + setContextRepositories(reposArray); + setAllPublicRepositories(reposArray.filter(r => r.is_public)); + } + } catch (err) { + setError('Failed to load repositories.'); + setContextRepositories([]); + setAllPublicRepositories([]); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + getRepositories(); + }, [token, organizationName]); + + const handleCreationSuccess = (newRepo: Repository) => { + setShowCreateForm(false); + setViewingRepository(newRepo); + // Manually add the new repo to the state to avoid a full refetch + setContextRepositories(prev => [newRepo, ...prev.filter(r => r.id !== newRepo.id)]); + // Only update community list if it's visible (i.e., in "All Orgs" view) + if (newRepo.is_public && !organizationName) { + setAllPublicRepositories(prev => [newRepo, ...prev.filter(r => r.id !== newRepo.id)]); + } + }; + + const { myRepositories, communityRepositories } = useMemo(() => { + const myFiltered = contextRepositories.filter(repo => + repo.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const communityFiltered = allPublicRepositories.filter(repo => + repo.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return { myRepositories: myFiltered, communityRepositories: communityFiltered }; + }, [contextRepositories, allPublicRepositories, searchTerm]); + + const handleBackToList = () => { + setViewingRepository(null); + }; + + const renderContent = () => { + if (isLoading) { + return
    Loading repositories...
    ; + } + if (error) { + return
    {error}
    ; + } + + const orgNameForDetail = organizationName || viewingRepository?.organization?.name; + + if (viewingRepository && orgNameForDetail) { + return ( + + ); + } + if (showCreateForm && organizationName) { // Can only create if we know the org + return ( + setShowCreateForm(false)} + /> + ); + } + return ( +
    +
    +

    + My Repositories + {organizationName && in {organizationName}} +

    + setViewingRepository(repo)} + /> +
    + + {/* Only show Community Repositories if no specific organization is selected */} + {!organizationName && ( +
    +

    + Community Repositories +

    + setViewingRepository(repo)} + /> +
    + )} +
    + ); + }; + + return ( +
    + {!viewingRepository && !showCreateForm && ( +
    +

    Repositories

    +
    +
    +
    + +
    + setSearchTerm(e.target.value)} + className="block w-full pl-10 pr-4 py-2 bg-slate-700 border border-slate-600 rounded-md text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + /> +
    + +
    +
    + )} +
    + {renderContent()} +
    +
    + ); +}; + +export default RepositoryBrowser; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/repository/RepositoryDetail.tsx b/app/Fe-AI-Decenter/components/repository/RepositoryDetail.tsx new file mode 100644 index 0000000..feb15cd --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/RepositoryDetail.tsx @@ -0,0 +1,137 @@ +import React, { useState } from 'react'; +import { Repository } from '../../types'; +import CommandSnippet from './CommandSnippet'; +import { ArrowLeftIcon } from '../icons/ArrowLeftIcon'; +import RepositorySettings from './RepositorySettings'; +import RepositoryTags from './RepositoryTags'; +import RepositoryUsage from './RepositoryUsage'; +import RepositoryWebhooks from './RepositoryWebhooks'; +import { TagIcon } from '../icons/TagIcon'; +import { ChartBarIcon } from '../icons/ChartBarIcon'; +import { RssIcon } from '../icons/RssIcon'; +import { CogIcon } from '../icons/CogIcon'; +import { CodeBracketIcon } from '../icons/CodeBracketIcon'; + +interface RepositoryDetailProps { + token: string; + repository: Repository; + organizationName: string; + onBack: () => void; +} + +const REGISTRY_HOST = 'registry.example.com'; // Placeholder for your registry's hostname + +const RepositoryDetail: React.FC = ({ token, repository, organizationName, onBack }) => { + const [activeTab, setActiveTab] = useState<'tags' | 'usage' | 'webhooks' | 'instructions' | 'settings'>('tags'); + + const repositoryPath = `${REGISTRY_HOST}/${organizationName}/${repository.name}`; + + return ( +
    +
    + +

    {repository.name}

    +

    {repository.description || 'No description provided.'}

    +
    + +
    + +
    + +
    + {activeTab === 'tags' && ( + + )} + {activeTab === 'usage' && ( + + )} + {activeTab === 'webhooks' && ( + + )} + {activeTab === 'instructions' && ( +
    +
    +

    + Push an image +

    +
    +
    +

    1. Log in to the registry

    + +
    +
    +

    2. Tag your local image

    + +
    +
    +

    3. Push the image

    + +
    +
    +
    +
    + )} + {activeTab === 'settings' && ( + + )} +
    +
    + ); +}; + +// Internal tab button component for styling +const TabButton: React.FC<{icon: React.ReactNode, label: string, isActive: boolean, onClick: () => void}> = ({ icon, label, isActive, onClick }) => ( + +); + + +export default RepositoryDetail; diff --git a/app/Fe-AI-Decenter/components/repository/RepositoryList.tsx b/app/Fe-AI-Decenter/components/repository/RepositoryList.tsx new file mode 100644 index 0000000..ef326fa --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/RepositoryList.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Repository } from '../../types'; +import RepositoryListItem from './RepositoryListItem'; + +interface RepositoryListProps { + repositories: Repository[]; + organizationName?: string; + onSelectRepository: (repository: Repository) => void; +} + +const RepositoryList: React.FC = ({ repositories, organizationName, onSelectRepository }) => { + if (repositories.length === 0) { + return ( +
    +

    No Repositories Found

    +

    Try adjusting your search or create a new repository.

    +
    + ); + } + return ( +
    + {repositories.map(repo => ( + + ))} +
    + ); +}; + +export default RepositoryList; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/repository/RepositoryListItem.tsx b/app/Fe-AI-Decenter/components/repository/RepositoryListItem.tsx new file mode 100644 index 0000000..577ae3a --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/RepositoryListItem.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import { Repository } from '../../types'; +import { LockIcon } from '../icons/LockIcon'; +import { GlobeIcon } from '../icons/GlobeIcon'; +import { ClipboardIcon } from '../icons/ClipboardIcon'; + +interface RepositoryListItemProps { + repository: Repository; + organizationName?: string; + onSelect: (repository: Repository) => void; +} + +const RepositoryListItem: React.FC = ({ repository, organizationName, onSelect }) => { + const isPrivate = !repository.is_public; + const [copyStatus, setCopyStatus] = useState('Copy'); + + // Use the nested organization name if available, otherwise fall back to the prop. + const orgName = repository.organization?.name || organizationName; + const pullCommand = `docker pull registry.example.com/${orgName}/${repository.name}:latest`; + + const handleCopy = () => { + navigator.clipboard.writeText(pullCommand).then(() => { + setCopyStatus('Copied!'); + setTimeout(() => setCopyStatus('Copy'), 2000); + }).catch(err => { + console.error('Failed to copy text: ', err); + setCopyStatus('Failed'); + setTimeout(() => setCopyStatus('Copy'), 2000); + }); + }; + + return ( +
    +
    +
    + + + {isPrivate ? : } + {isPrivate ? 'private' : 'public'} + +
    +

    + {repository.description || 'No description provided.'} +

    +
    +
    +
    + + +
    +
    +
    + ); +}; + +export default RepositoryListItem; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/repository/RepositorySettings.tsx b/app/Fe-AI-Decenter/components/repository/RepositorySettings.tsx new file mode 100644 index 0000000..b7c9fda --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/RepositorySettings.tsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react'; +import { deleteRepository } from '../../services/api'; +import { Repository } from '../../types'; +import Button from '../Button'; +import Modal from '../Modal'; +import { TrashIcon } from '../icons/TrashIcon'; +import Input from '../Input'; + +interface RepositorySettingsProps { + token: string; + organizationName: string; + repository: Repository; + onRepositoryDeleted: () => void; +} + +const RepositorySettings: React.FC = ({ + token, + organizationName, + repository, + onRepositoryDeleted +}) => { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [deleteConfirmationName, setDeleteConfirmationName] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + const handleDelete = async () => { + if (deleteConfirmationName !== repository.name) { + setDeleteError(`Type "${repository.name}" to confirm.`); + return; + } + setIsDeleting(true); + setDeleteError(null); + try { + await deleteRepository(organizationName, repository.name, token); + setIsDeleteModalOpen(false); + onRepositoryDeleted(); + } catch (err) { + setDeleteError('Failed to delete repository. Please try again.'); + console.error(err); + } finally { + setIsDeleting(false); + } + }; + + return ( +
    +
    +

    Danger Zone

    +

    Deleting a repository is permanent and cannot be undone. This will delete all associated tags and images.

    + +
    + + setIsDeleteModalOpen(false)} + title="Delete Repository" + > +
    +

    + Are you sure you want to delete the repository {repository.name}? + This action is irreversible. +

    +

    To confirm, please type {repository.name} in the box below.

    + setDeleteConfirmationName(e.target.value)} + placeholder={repository.name} + /> + {deleteError &&

    {deleteError}

    } +
    + + +
    +
    +
    +
    + ); +}; + +export default RepositorySettings; diff --git a/app/Fe-AI-Decenter/components/repository/RepositoryTagDetail.tsx b/app/Fe-AI-Decenter/components/repository/RepositoryTagDetail.tsx new file mode 100644 index 0000000..ab6c3b3 --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/RepositoryTagDetail.tsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react'; +import { ImageTag } from '../../types'; +import { ArrowLeftIcon } from '../icons/ArrowLeftIcon'; +import { ClipboardIcon } from '../icons/ClipboardIcon'; +import { ClockIcon } from '../icons/ClockIcon'; +import { CubeIcon } from '../icons/CubeIcon'; +import Button from '../Button'; +import Modal from '../Modal'; +import { TrashIcon } from '../icons/TrashIcon'; + +interface RepositoryTagDetailProps { + tag: ImageTag; + repositoryPath: string; + onBack: () => void; +} + +const RepositoryTagDetail: React.FC = ({ tag, repositoryPath, onBack }) => { + const [copyStatus, setCopyStatus] = useState('Copy'); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // Pull command with specific digest for reproducibility + const pullCommand = `docker pull ${repositoryPath}@${tag.digest}`; + + const handleCopy = () => { + navigator.clipboard.writeText(pullCommand).then(() => { + setCopyStatus('Copied!'); + setTimeout(() => setCopyStatus('Copy'), 2000); + }).catch(err => { + console.error('Failed to copy text: ', err); + setCopyStatus('Failed'); + setTimeout(() => setCopyStatus('Copy'), 2000); + }); + }; + + const handleDelete = () => { + setIsDeleting(true); + // Simulate API call + setTimeout(() => { + setIsDeleting(false); + setIsDeleteModalOpen(false); + onBack(); // Go back to list after "deletion" + }, 1500); + }; + + return ( +
    +
    + +

    Tag: {tag.name}

    +
    + + {/* --- Main Info --- */} +
    +
    + +

    {tag.digest}

    +
    +
    + +

    {new Date(tag.config.created).toLocaleString()}

    +
    +
    + +
    +
    + + {/* --- Config & Labels --- */} +
    +

    Configuration

    +
    + + + {Object.entries(tag.config.labels).map(([key, value]) => ( + + ))} +
    +
    + + {/* --- History --- */} + }> + + + Command + Details + + + + {tag.history.map((item, index) => ( + + {item.command} + {item.details} + + ))} + + + + {/* --- Layers --- */} + }> + + + Digest + Size + + + + {tag.layers.map((layer) => ( + + {layer.digest} + {layer.size} + + ))} + + + + {/* --- Danger Zone --- */} +
    +

    Danger Zone

    +

    Deleting a tag is permanent and cannot be undone unless the underlying image digest is referenced by another tag.

    + +
    + + setIsDeleteModalOpen(false)} + title="Delete Tag" + > +
    +

    + Are you sure you want to delete the tag {tag.name}? + This action is irreversible. +

    +
    + + +
    +
    +
    + +
    + ); +}; + + +const InfoPair: React.FC<{label: string, value: string}> = ({ label, value }) => ( + <> +
    {label}
    +
    {value}
    + +); + +const TableSection: React.FC<{ title: string; icon: React.ReactNode; children: React.ReactNode }> = ({ title, icon, children }) => ( +
    +
    + {icon} +

    {title}

    +
    +
    +
    + + {children} +
    +
    +
    +
    +); + + +export default RepositoryTagDetail; diff --git a/app/Fe-AI-Decenter/components/repository/RepositoryTags.tsx b/app/Fe-AI-Decenter/components/repository/RepositoryTags.tsx new file mode 100644 index 0000000..c159e58 --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/RepositoryTags.tsx @@ -0,0 +1,204 @@ +import React, { useState } from 'react'; +import { ImageTag } from '../../types'; +import { ClipboardIcon } from '../icons/ClipboardIcon'; +import { TagIcon } from '../icons/TagIcon'; +import RepositoryTagDetail from './RepositoryTagDetail'; + +// Mock data since API is not available, now with detailed info +const mockTags: ImageTag[] = [ + { + name: 'latest', + digest: 'sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + osArch: 'linux/amd64', + size: '128 MB', + pushedAt: '3 hours ago', + config: { + created: '2024-07-28T10:30:00Z', + dockerVersion: '20.10.7', + osArch: 'linux/amd64', + labels: { + 'maintainer': 'Aerugo Team ', + 'version': '1.2.1', + }, + }, + history: [ + { command: 'CMD', details: '["/bin/sh", "-c", "node server.js"]' }, + { command: 'EXPOSE', details: '8080' }, + { command: 'COPY', details: '. .' }, + { command: 'RUN', details: 'npm install --production' }, + { command: 'WORKDIR', details: '/app' }, + { command: 'FROM', details: 'node:18-alpine' }, + ], + layers: [ + { digest: 'sha256:1a2b3c4d...', size: '5.5 MB' }, + { digest: 'sha256:5e6f7g8h...', size: '12.1 MB' }, + { digest: 'sha256:9i0j1k2l...', size: '110.4 MB' }, + ], + }, + { + name: 'v1.2.1', + digest: 'sha256:f0e9d8c7b6a5f0e9d8c7b6a5f0e9d8c7b6a5f0e9d8c7b6a5f0e9d8c7b6a5f0e9', + osArch: 'linux/amd64', + size: '127 MB', + pushedAt: '2 days ago', + config: { + created: '2024-07-26T14:00:00Z', + dockerVersion: '20.10.7', + osArch: 'linux/amd64', + labels: { + 'maintainer': 'Aerugo Team ', + 'version': '1.2.1', + }, + }, + history: [ + { command: 'CMD', details: '["/bin/sh", "-c", "node server.js"]' }, + { command: 'EXPOSE', details: '8080' }, + { command: 'COPY', details: '. .' }, + { command: 'RUN', details: 'npm install --production' }, + { command: 'WORKDIR', details: '/app' }, + { command: 'FROM', details: 'node:18-alpine' }, + ], + layers: [ + { digest: 'sha256:1a2b3c4d...', size: '5.5 MB' }, + { digest: 'sha256:5e6f7g8h...', size: '12.1 MB' }, + { digest: 'sha256:9i0j1k2l...', size: '109.4 MB' }, + ], + }, + { + name: 'v1.2.0', + digest: 'sha256:b1a2c3d4e5f6b1a2c3d4e5f6b1a2c3d4e5f6b1a2c3d4e5f6b1a2c3d4e5f6b1a2', + osArch: 'linux/amd64', + size: '126 MB', + pushedAt: '1 week ago', + config: { + created: '2024-07-21T09:15:00Z', + dockerVersion: '20.10.6', + osArch: 'linux/amd64', + labels: { + 'maintainer': 'Aerugo Team ', + 'version': '1.2.0', + }, + }, + history: [ + { command: 'CMD', details: '["/bin/sh", "-c", "node server.js"]' }, + { command: 'EXPOSE', details: '8080' }, + { command: 'COPY', details: '. .' }, + { command: 'RUN', details: 'npm install' }, + { command: 'WORKDIR', details: '/app' }, + { command: 'FROM', details: 'node:18-alpine' }, + ], + layers: [ + { digest: 'sha256:1a2b3c4d...', size: '5.5 MB' }, + { digest: 'sha256:5e6f7g8h...', size: '12.1 MB' }, + { digest: 'sha256:3m4n5o6p...', size: '108.4 MB' }, + ], + }, +]; + +interface RepositoryTagsProps { + repositoryPath: string; +} + +const RepositoryTags: React.FC = ({ repositoryPath }) => { + const [selectedTag, setSelectedTag] = useState(null); + + if (selectedTag) { + return ( + setSelectedTag(null)} + /> + ); + } + + if (mockTags.length === 0) { + return ( +
    + +

    No Tags Found

    +

    This repository is empty. Push an image to see its tags here.

    +
    + ); + } + + return ( +
    +
    + + + + + + + + + + + + + {mockTags.map((tag) => ( + setSelectedTag(tag)} + /> + ))} + +
    TagDigestOS/ArchSizePushedCopy Pull Command
    +
    +
    + ); +}; + +interface TagListItemProps { + tag: ImageTag; + repositoryPath: string; + onSelectTag: () => void; +} + +const TagListItem: React.FC = ({ tag, repositoryPath, onSelectTag }) => { + const [copyStatus, setCopyStatus] = useState('Copy'); + const pullCommand = `docker pull ${repositoryPath}:${tag.name}`; + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent triggering onSelectTag + navigator.clipboard.writeText(pullCommand).then(() => { + setCopyStatus('Copied!'); + setTimeout(() => setCopyStatus('Copy'), 2000); + }).catch(err => { + console.error('Failed to copy text: ', err); + setCopyStatus('Failed'); + setTimeout(() => setCopyStatus('Copy'), 2000); + }); + }; + + return ( + + + + + + {tag.digest.substring(0, 19)}... + + {tag.osArch} + {tag.size} + {tag.pushedAt} + + + + + ); +}; + +export default RepositoryTags; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/components/repository/RepositoryUsage.tsx b/app/Fe-AI-Decenter/components/repository/RepositoryUsage.tsx new file mode 100644 index 0000000..7c0fb3f --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/RepositoryUsage.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { ChartBarIcon } from '../icons/ChartBarIcon'; + +const StatCard: React.FC<{ title: string; value: string }> = ({ title, value }) => ( +
    +

    {title}

    +

    {value}

    +
    +); + +const RepositoryUsage: React.FC = () => { + return ( +
    +
    + + + +
    + +
    +

    Pulls in the Last 30 Days

    +
    +
    + +

    Chart data is not yet available.

    +
    +
    +
    +
    + ); +}; + +export default RepositoryUsage; diff --git a/app/Fe-AI-Decenter/components/repository/RepositoryWebhooks.tsx b/app/Fe-AI-Decenter/components/repository/RepositoryWebhooks.tsx new file mode 100644 index 0000000..a5d3734 --- /dev/null +++ b/app/Fe-AI-Decenter/components/repository/RepositoryWebhooks.tsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react'; +import { Webhook } from '../../types'; +import Button from '../Button'; +import Input from '../Input'; +import { PlusIcon } from '../icons/PlusIcon'; +import { RssIcon } from '../icons/RssIcon'; + +const mockWebhooks: Webhook[] = [ + { + id: 1, + url: 'https://ci.example.com/hooks/aerugo', + events: ['on_push'], + lastDelivery: { + status: 'success', + timestamp: '2 hours ago', + } + }, + { + id: 2, + url: 'https://notifications.example.com/slack', + events: ['on_push', 'on_delete'], + lastDelivery: { + status: 'failed', + timestamp: '1 day ago', + } + } +]; + +const RepositoryWebhooks: React.FC = () => { + const [showAddForm, setShowAddForm] = useState(false); + + return ( +
    +
    +

    Webhooks

    + {!showAddForm && ( + + )} +
    + + {showAddForm && ( + setShowAddForm(false)} /> + )} + + {mockWebhooks.length > 0 ? ( +
    +
      + {mockWebhooks.map(hook => )} +
    +
    + ) : ( +
    + +

    No Webhooks Configured

    +

    Add a webhook to be notified of repository events.

    +
    + )} +
    + ); +}; + +const AddWebhookForm: React.FC<{ onCancel: () => void }> = ({ onCancel }) => { + return ( +
    +
    + + + +
    + +
    + + +
    +
    + +
    + + +
    +
    +
    + ); +}; + +const WebhookListItem: React.FC<{ hook: Webhook }> = ({ hook }) => { + const isSuccess = hook.lastDelivery?.status === 'success'; + return ( +
  • +
    +

    {hook.url}

    +
    + {hook.lastDelivery && ( + + {isSuccess ? 'Success' : 'Failed'} + + )} + + {hook.lastDelivery ? `Last delivery: ${hook.lastDelivery.timestamp}` : 'Never delivered'} + +
    +
    +
  • + ); +}; + +export default RepositoryWebhooks; diff --git a/app/Fe-AI-Decenter/config.ts b/app/Fe-AI-Decenter/config.ts new file mode 100644 index 0000000..4dc30d7 --- /dev/null +++ b/app/Fe-AI-Decenter/config.ts @@ -0,0 +1 @@ +export const API_BASE_URL = 'http://localhost:8080'; diff --git a/app/Fe-AI-Decenter/index.html b/app/Fe-AI-Decenter/index.html new file mode 100644 index 0000000..3c77327 --- /dev/null +++ b/app/Fe-AI-Decenter/index.html @@ -0,0 +1,34 @@ + + + + + + + Aerugo Registry + + + + + +
    + + + \ No newline at end of file diff --git a/app/Fe-AI-Decenter/index.tsx b/app/Fe-AI-Decenter/index.tsx new file mode 100644 index 0000000..aaa0c6e --- /dev/null +++ b/app/Fe-AI-Decenter/index.tsx @@ -0,0 +1,16 @@ + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); diff --git a/app/Fe-AI-Decenter/metadata.json b/app/Fe-AI-Decenter/metadata.json new file mode 100644 index 0000000..bc3daba --- /dev/null +++ b/app/Fe-AI-Decenter/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "Aerugo Registry UI", + "description": "A web interface for the Aerugo container registry, allowing management of organizations, repositories, and members.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/app/Fe-AI-Decenter/package-lock.json b/app/Fe-AI-Decenter/package-lock.json new file mode 100644 index 0000000..e846080 --- /dev/null +++ b/app/Fe-AI-Decenter/package-lock.json @@ -0,0 +1,1686 @@ +{ + "name": "aerugo-registry-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aerugo-registry-ui", + "version": "0.0.0", + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "6.25.1" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz", + "integrity": "sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.35", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", + "integrity": "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.0.tgz", + "integrity": "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.0.tgz", + "integrity": "sha512-pqDirm8koABIKvzL59YI9W9DWbRlTX7RWhN+auR8HXJxo89m4mjqbah7nJZjeKNTNYopqL+yGg+0mhCpf3xZtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.0.tgz", + "integrity": "sha512-YCdWlY/8ltN6H78HnMsRHYlPiKvqKagBP1r+D7SSylxX+HnsgXGCmLiV3Y4nSyY9hW8qr8U9LDUx/Lo7M6MfmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.0.tgz", + "integrity": "sha512-z4nw6y1j+OOSGzuVbSWdIp1IUks9qNw4dc7z7lWuWDKojY38VMWBlEN7F9jk5UXOkUcp97vA1N213DF+Lz8BRg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.0.tgz", + "integrity": "sha512-Q/dv9Yvyr5rKlK8WQJZVrp5g2SOYeZUs9u/t2f9cQ2E0gJjYB/BWoedXfUT0EcDJefi2zzVfhcOj8drWCzTviw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.0.tgz", + "integrity": "sha512-kdBsLs4Uile/fbjZVvCRcKB4q64R+1mUq0Yd7oU1CMm1Av336ajIFqNFovByipciuUQjBCPMxwJhCgfG2re3rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.0.tgz", + "integrity": "sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.0.tgz", + "integrity": "sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.0.tgz", + "integrity": "sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.0.tgz", + "integrity": "sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.0.tgz", + "integrity": "sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.0.tgz", + "integrity": "sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.0.tgz", + "integrity": "sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.0.tgz", + "integrity": "sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.0.tgz", + "integrity": "sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.0.tgz", + "integrity": "sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.0.tgz", + "integrity": "sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.0.tgz", + "integrity": "sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.0.tgz", + "integrity": "sha512-YQugafP/rH0eOOHGjmNgDURrpYHrIX0yuojOI8bwCyXwxC9ZdTd3vYkmddPX0oHONLXu9Rb1dDmT0VNpjkzGGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.0.tgz", + "integrity": "sha512-zYdUYhi3Qe2fndujBqL5FjAFzvNeLxtIqfzNEVKD1I7C37/chv1VxhscWSQHTNfjPCrBFQMnynwA3kpZpZ8w4A==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.0.tgz", + "integrity": "sha512-fGk03kQylNaCOQ96HDMeT7E2n91EqvCDd3RwvT5k+xNdFCeMGnj5b5hEgTGrQuyidqSsD3zJDQ21QIaxXqTBJw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.0.tgz", + "integrity": "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.18.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", + "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.3.tgz", + "integrity": "sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.35", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", + "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001743", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", + "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.222", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.222.tgz", + "integrity": "sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.25.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.25.1.tgz", + "integrity": "sha512-u8ELFr5Z6g02nUtpPAggP73Jigj1mRePSwhS/2nkTrlPU5yEkH1vYzWNyvSnSzeeE2DNqWdH+P8OhIh9wuXhTw==", + "dependencies": { + "@remix-run/router": "1.18.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.25.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.25.1.tgz", + "integrity": "sha512-0tUDpbFvk35iv+N89dWNrJp+afLgd+y4VtorJZuOCXK0kkCWjEvb3vTJM++SYvMEpbVwXKf3FjeVveVEb6JpDQ==", + "dependencies": { + "@remix-run/router": "1.18.0", + "react-router": "6.25.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.0.tgz", + "integrity": "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.0", + "@rollup/rollup-android-arm64": "4.52.0", + "@rollup/rollup-darwin-arm64": "4.52.0", + "@rollup/rollup-darwin-x64": "4.52.0", + "@rollup/rollup-freebsd-arm64": "4.52.0", + "@rollup/rollup-freebsd-x64": "4.52.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", + "@rollup/rollup-linux-arm-musleabihf": "4.52.0", + "@rollup/rollup-linux-arm64-gnu": "4.52.0", + "@rollup/rollup-linux-arm64-musl": "4.52.0", + "@rollup/rollup-linux-loong64-gnu": "4.52.0", + "@rollup/rollup-linux-ppc64-gnu": "4.52.0", + "@rollup/rollup-linux-riscv64-gnu": "4.52.0", + "@rollup/rollup-linux-riscv64-musl": "4.52.0", + "@rollup/rollup-linux-s390x-gnu": "4.52.0", + "@rollup/rollup-linux-x64-gnu": "4.52.0", + "@rollup/rollup-linux-x64-musl": "4.52.0", + "@rollup/rollup-openharmony-arm64": "4.52.0", + "@rollup/rollup-win32-arm64-msvc": "4.52.0", + "@rollup/rollup-win32-ia32-msvc": "4.52.0", + "@rollup/rollup-win32-x64-gnu": "4.52.0", + "@rollup/rollup-win32-x64-msvc": "4.52.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/app/Fe-AI-Decenter/package.json b/app/Fe-AI-Decenter/package.json new file mode 100644 index 0000000..0580252 --- /dev/null +++ b/app/Fe-AI-Decenter/package.json @@ -0,0 +1,22 @@ +{ + "name": "aerugo-registry-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-router-dom": "6.25.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/app/Fe-AI-Decenter/pages/AuthPage.tsx b/app/Fe-AI-Decenter/pages/AuthPage.tsx new file mode 100644 index 0000000..3f0139a --- /dev/null +++ b/app/Fe-AI-Decenter/pages/AuthPage.tsx @@ -0,0 +1,254 @@ + +import React, { useState, useRef } from 'react'; +import { Link } from 'react-router-dom'; +import LoginForm from '../components/LoginForm'; +import RegisterForm from '../components/RegisterForm'; +import { AuthMode } from '../types'; +import { AerugoIcon } from '../components/icons/DockerIcon'; +import { BriefcaseIcon } from '../components/icons/BriefcaseIcon'; +import { ShieldCheckIcon } from '../components/icons/ShieldCheckIcon'; +import { CodeBracketIcon } from '../components/icons/CodeBracketIcon'; +import { ServerStackIcon } from '../components/icons/ServerStackIcon'; +import { UsersIcon } from '../components/icons/UsersIcon'; +import { CloudIcon } from '../components/icons/CloudIcon'; +import { RocketLaunchIcon } from '../components/icons/RocketLaunchIcon'; + + +interface AuthPageProps { + onLoginSuccess: (token: string) => void; +} + +const AuthPage: React.FC = ({ onLoginSuccess }) => { + const [authMode, setAuthMode] = useState(AuthMode.Register); + const authSectionRef = useRef(null); + const introductionSectionRef = useRef(null); + + const switchMode = (mode: AuthMode) => { + setAuthMode(mode); + }; + + const handleRegisterSuccess = () => { + setAuthMode(AuthMode.Login); + }; + + const handleGetStartedClick = () => { + setAuthMode(AuthMode.Register); + authSectionRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + const handleDocsClick = () => { + introductionSectionRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + return ( +
    + {/* Header */} +
    + +
    + + {/* Hero Section */} +
    +
    +

    + The Modern, Secure Container Registry +

    +

    + Streamline your development workflow with a private, scalable, and easy-to-use registry for all your container images. +

    +
    + +
    +
    +
    + + {/* Introduction Section */} +
    +
    +
    +

    Built for Performance, Security, and Scale

    +

    + Aerugo is a next-generation, distributed, and multi-tenant container registry built with Rust. It is designed for high performance and scalability, leveraging an S3-compatible object storage backend. +

    +
    +
    + } + title="Distributed & Highly Available" + description="Designed to run in a clustered environment with no single point of failure." + /> + } + title="Multi-tenancy" + description="First-class support for users and organizations with granular access control." + /> + } + title="S3-Compatible Backend" + description="Uses any S3-compatible object storage for durability and infinite scalability." + /> + } + title="Written in Rust" + description="Provides memory safety, concurrency, and performance for a secure, efficient core." + /> +
    +
    +
    + + {/* Features Section */} +
    +
    +
    +

    Built for Developers and Teams

    +

    Everything you need, nothing you don't.

    +
    +
    + } + title="Organize Repositories" + description="Group your repositories under organizations to easily manage access and billing for your entire team." + /> + } + title="Secure & Private" + description="Control who can see and pull your images with public/private repositories and fine-grained permissions." + /> + } + title="Developer Friendly" + description="A clean, intuitive UI and a straightforward API make managing your images a breeze. Works with standard Docker commands." + /> +
    +
    +
    + + {/* Auth Section */} +
    +
    +

    + {authMode === AuthMode.Login ? 'Welcome Back' : 'Create Your Account'} +

    +

    + {authMode === AuthMode.Login ? 'Sign in to manage your repositories.' : 'Join now to start pushing images.'} +

    + +
    + {authMode === AuthMode.Login ? ( + + ) : ( + + )} +
    + +
    + {authMode === AuthMode.Login ? ( +

    + Don't have an account?{' '} + +

    + ) : ( +

    + Already have an account?{' '} + +

    + )} +
    +
    +
    + + {/* Footer */} +
    +
    +

    © {new Date().getFullYear()} Aerugo Registry. All rights reserved.

    +
    + + Terms of Service + +
    +
    +
    +
    + ); +}; + + +interface IntroFeatureProps { + icon: React.ReactNode; + title: string; + description: string; +} + +const IntroFeature: React.FC = ({ icon, title, description }) => ( +
    +
    + {icon} +
    +

    {title}

    +

    {description}

    +
    +); + + +interface FeatureCardProps { + icon: React.ReactNode; + title: string; + description: string; +} + +const FeatureCard: React.FC = ({ icon, title, description }) => ( +
    +
    + {icon} +
    +

    {title}

    +

    {description}

    +
    +); + + +export default AuthPage; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/pages/DocsPage.tsx b/app/Fe-AI-Decenter/pages/DocsPage.tsx new file mode 100644 index 0000000..194df9b --- /dev/null +++ b/app/Fe-AI-Decenter/pages/DocsPage.tsx @@ -0,0 +1,215 @@ + +import React, { useEffect } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import CodeBlock from '../components/docs/CodeBlock'; + +const DocsPage: React.FC = () => { + const navItems = [ + { href: '#introduction', label: 'Introduction' }, + { href: '#organizations', label: 'Managing Organizations' }, + { href: '#repositories', label: 'Managing Repositories' }, + { href: '#docker-usage', label: 'Using Docker' }, + { href: '#tos', label: 'Terms of Service' }, + ]; + + const REGISTRY_HOST = 'registry.example.com'; // Placeholder, matches other components + + const location = useLocation(); + + useEffect(() => { + // Handle scrolling to the correct section when the hash in the URL changes. + // This is needed for links from other pages (e.g., footer) to work correctly. + if (location.hash) { + const id = location.hash.substring(1); + setTimeout(() => { + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }, 100); // Small delay to ensure the page has rendered + } + }, [location.hash]); + + const handleNavClick = (e: React.MouseEvent, targetId: string) => { + e.preventDefault(); + const id = targetId.substring(1); + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + // Update the URL hash without reloading the page + window.history.pushState(null, '', targetId); + } + }; + + return ( +
    + {/* Sidebar */} + + + {/* Main content */} +
    +
    +
    +

    User Guide

    +

    + Welcome to the Aerugo Registry! This guide will walk you through managing your container images using this web interface. Here you can create organizations for your teams, manage your repositories, and control access for your members. +

    +
    + + Get Started + +
    +
    + +
    +

    Managing Organizations

    +
    +

    An organization acts as a workspace for your team. It contains repositories and members. You can create multiple organizations to separate projects or teams.

    + +

    Creating an Organization

    +
      +
    1. Navigate to the Organizations page from the main navigation.
    2. +
    3. Click the "Create New" button.
    4. +
    5. + Fill in the form: +
        +
      • Display Name: The name that will be shown throughout the UI (e.g., "My Awesome Team").
      • +
      • Organization Name (URL): A unique, URL-friendly identifier for your organization. This is used in docker commands (e.g., "my-awesome-team"). It must be lowercase and can only contain letters, numbers, and hyphens.
      • +
      • The other fields are optional and can be used to add more detail.
      • +
      +
    6. +
    7. Click "Create Organization" to finish.
    8. +
    + +

    Managing Members

    +

    Once you've selected an organization, you can manage its members from the "Members" tab.

    +
      +
    • Adding a Member: Click "Add Member", enter the user's registered email address, and assign them a role.
    • +
    • Changing Roles: You can change a member's role using the dropdown next to their name.
    • +
    • Removing a Member: Click the trash icon to remove a member from the organization.
    • +
    + +

    Member Roles

    +
      +
    • Owner: Has full administrative control over the organization, its repositories, and its members. Can delete the organization.
    • +
    • Admin: Can manage repositories and members (except other Owners).
    • +
    • Member: Can view and (depending on repository settings) push/pull images within the organization.
    • +
    +
    +
    + +
    +

    Managing Repositories

    +
    +

    A repository is a collection of related container images, identified by different tags (e.g., :latest, :v1.0). For example, you might have a repository named my-app to store all versions of your application's image.

    + +

    Creating a Repository

    +
      +
    1. Navigate to the Repositories page.
    2. +
    3. From the dropdown menu in the top-right, select the organization you want to create the repository in.
    4. +
    5. Click the "Create Repository" button.
    6. +
    7. Name your repository. This name will be part of the image URL.
    8. +
    9. Choose the repository's Visibility.
    10. +
    + +

    Public vs. Private Repositories

    +
      +
    • Public: Anyone, even unauthenticated users, can pull images from this repository.
    • +
    • Private: Only members of the organization can pull images. You control who has access.
    • +
    +
    +
    + +
    +

    Using Docker

    +
    +

    To push and pull images, you'll use the Docker command-line tool. The following commands show you how.

    + +

    1. Log In to the Registry

    +

    First, you need to log in with your Aerugo account credentials. This command only needs to be run once.

    + + +

    2. Tag Your Image

    +

    Before you can push a local image, you must tag it with the full registry path. The format is {REGISTRY_HOST}/[organization-name]/[repository-name]:[tag].

    + + +

    3. Push the Image

    +

    Now, push the tagged image to the registry.

    + +

    You should now see the new tag appear in the repository details within the UI.

    + +

    4. Pull the Image

    +

    To pull the image on another machine (or after removing it locally), use the same full path.

    + +
    +
    + +
    +

    Terms of Service

    +
    +

    Last Updated: {new Date().toLocaleDateString()}

    + +

    1. Acceptance of Terms

    +

    By accessing or using the Aerugo Registry service ("Service"), you agree to be bound by these Terms of Service ("Terms"). If you disagree with any part of the terms, then you may not access the Service.

    + +

    2. User Accounts

    +

    You are responsible for safeguarding the password that you use to access the Service and for any activities or actions under your password. You agree not to disclose your password to any third party. You must notify us immediately upon becoming aware of any breach of security or unauthorized use of your account.

    + +

    3. User Content

    +

    You retain full ownership of any container images, data, or other content you upload to the Service ("Content"). By uploading Content, you grant us a worldwide, non-exclusive, royalty-free license to host, store, and distribute your Content solely for the purpose of providing and operating the Service. You are solely responsible for your Content and the consequences of storing and distributing it.

    +

    You represent and warrant that you have all necessary rights to your Content and that your Content does not infringe upon any third-party rights, contain any malware, or violate any applicable laws.

    + +

    4. Acceptable Use

    +

    You agree not to use the Service for any purpose that is illegal or prohibited by these Terms. You agree not to:

    +
      +
    • Upload or distribute any Content that is unlawful, harmful, or infringes on the intellectual property rights of others.
    • +
    • Engage in any activity that interferes with or disrupts the Service (or the servers and networks which are connected to the Service).
    • +
    • Attempt to gain unauthorized access to any part of the Service, other accounts, or computer systems.
    • +
    + +

    5. Termination

    +

    We may terminate or suspend your access to our Service immediately, without prior notice or liability, for any reason whatsoever, including without limitation if you breach the Terms. Upon termination, your right to use the Service will immediately cease. We reserve the right to delete your account and all associated Content upon termination.

    + +

    6. Disclaimer of Warranties

    +

    The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, regarding the reliability, security, or availability of the Service.

    + +

    7. Limitation of Liability

    +

    In no event shall Aerugo Registry, nor its directors, employees, partners, or agents, be liable for any indirect, incidental, special, consequential or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your access to or use of or inability to access or use the Service.

    + +

    8. Changes to Terms

    +

    We reserve the right, at our sole discretion, to modify or replace these Terms at any time. We will provide notice of any changes by posting the new Terms on this page. Your continued use of the Service after any such changes constitutes your acceptance of the new Terms.

    +
    +
    +
    +
    +
    + ); +}; + +export default DocsPage; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/pages/ForgotPasswordPage.tsx b/app/Fe-AI-Decenter/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..689ef78 --- /dev/null +++ b/app/Fe-AI-Decenter/pages/ForgotPasswordPage.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { AerugoIcon } from '../components/icons/DockerIcon'; +import Input from '../components/Input'; +import Button from '../components/Button'; +import { forgotPassword } from '../services/api'; + +const ForgotPasswordPage: React.FC = () => { + const [email, setEmail] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isSubmitted, setIsSubmitted] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!email) { + setError('Please enter your email address.'); + return; + } + + setIsLoading(true); + try { + // This currently uses the placeholder API function + await forgotPassword({ email }); + setIsSubmitted(true); + } catch (err) { + // In a real scenario, you might not want to reveal if an email exists or not + // So a generic success message is often better regardless of the outcome. + // For now, we'll assume the happy path and show the success state. + console.error(err); + setIsSubmitted(true); + } finally { + setIsLoading(false); + } + }; + + return ( +
    +
    +
    + + + +
    + +
    + {isSubmitted ? ( +
    +

    Check Your Email

    +

    + If an account exists for {email}, you will receive an email with instructions on how to reset your password. +

    +
    + + ← Back to Sign In + +
    +
    + ) : ( + <> +

    + Forgot Password? +

    +

    + Enter your email and we'll send you a reset link. +

    +
    + setEmail(e.target.value)} + placeholder="you@example.com" + disabled={isLoading} + autoComplete="email" + /> + {error &&

    {error}

    } + +
    + + )} +
    + {!isSubmitted && ( +
    + + ← Back to Sign In + +
    + )} +
    +
    + ); +}; + +export default ForgotPasswordPage; diff --git a/app/Fe-AI-Decenter/pages/OrganizationsPage.tsx b/app/Fe-AI-Decenter/pages/OrganizationsPage.tsx new file mode 100644 index 0000000..dc5ca2d --- /dev/null +++ b/app/Fe-AI-Decenter/pages/OrganizationsPage.tsx @@ -0,0 +1,47 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import OrganizationsManager from '../components/organization/OrganizationsManager'; +import { Organization, User } from '../types'; +import { fetchOrganizations } from '../services/api'; + +interface OrganizationsPageProps { + token: string; + currentUser: User; +} + +const OrganizationsPage: React.FC = ({ token, currentUser }) => { + const [organizations, setOrganizations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const getOrganizations = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + const orgs = await fetchOrganizations(token); + setOrganizations(Array.isArray(orgs) ? orgs : []); + } catch (err) { + setError('Failed to load organizations.'); + setOrganizations([]); + console.error(err); + } finally { + setIsLoading(false); + } + }, [token]); + + useEffect(() => { + getOrganizations(); + }, [getOrganizations]); + + return ( + + ); +}; + +export default OrganizationsPage; diff --git a/app/Fe-AI-Decenter/pages/ProfilePage.tsx b/app/Fe-AI-Decenter/pages/ProfilePage.tsx new file mode 100644 index 0000000..b7112bc --- /dev/null +++ b/app/Fe-AI-Decenter/pages/ProfilePage.tsx @@ -0,0 +1,155 @@ + +import React, { useState } from 'react'; +import { User, ChangePasswordRequest } from '../types'; +import { changePassword } from '../services/api'; +import { UserCircleIcon } from '../components/icons/UserCircleIcon'; +import Input from '../components/Input'; +import Button from '../components/Button'; + +interface ProfilePageProps { + currentUser: User; + token: string; +} + +const ProfilePage: React.FC = ({ currentUser, token }) => { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + + if (!currentPassword || !newPassword || !confirmPassword) { + setError('Please fill in all fields.'); + return; + } + if (newPassword.length < 8) { + setError('New password must be at least 8 characters long.'); + return; + } + if (newPassword !== confirmPassword) { + setError('New passwords do not match.'); + return; + } + + setIsLoading(true); + try { + const payload: ChangePasswordRequest = { + current_password: currentPassword, + new_password: newPassword, + confirm_password: confirmPassword, + }; + await changePassword(payload, token); + setSuccess('Password updated successfully!'); + // Clear fields on success + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch (err: any) { + if (err.status === 401) { + setError('Incorrect current password.'); + } else { + setError('An unexpected error occurred. Please try again.'); + } + console.error(err); + } finally { + setIsLoading(false); + } + }; + + + return ( +
    +
    +

    My Profile

    +

    View and manage your account details.

    +
    + +
    +
    +
    + + + +
    +
    +

    {currentUser.username}

    +

    {currentUser.email}

    +
    +
    +
    + +
    +
    +

    Account Information

    +

    These are your account details. Profile editing is not yet available.

    +
    +
    +
    +
    +
    Username
    +
    {currentUser.username}
    +
    +
    +
    Email address
    +
    {currentUser.email}
    +
    +
    +
    User ID
    +
    {currentUser.id}
    +
    +
    +
    +
    + +
    +

    Change Password

    +
    + setCurrentPassword(e.target.value)} + disabled={isLoading} + autoComplete="current-password" + /> + setNewPassword(e.target.value)} + disabled={isLoading} + autoComplete="new-password" + /> + setConfirmPassword(e.target.value)} + disabled={isLoading} + autoComplete="new-password" + /> + {error &&

    {error}

    } + {success &&

    {success}

    } +
    + +
    +
    +
    + +
    + ); +}; + +export default ProfilePage; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/pages/RepositoriesPage.tsx b/app/Fe-AI-Decenter/pages/RepositoriesPage.tsx new file mode 100644 index 0000000..47b7ec4 --- /dev/null +++ b/app/Fe-AI-Decenter/pages/RepositoriesPage.tsx @@ -0,0 +1,64 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import OrganizationSelector from '../components/organization/OrganizationSelector'; +import RepositoryBrowser from '../components/repository/RepositoryBrowser'; +import { Organization } from '../types'; +import { fetchOrganizations } from '../services/api'; + +interface RepositoriesPageProps { + token: string; +} + +const RepositoriesPage: React.FC = ({ token }) => { + const [selectedOrgId, setSelectedOrgId] = useState(null); + const [organizations, setOrganizations] = useState([]); + const [isLoadingOrgs, setIsLoadingOrgs] = useState(true); + const [orgsError, setOrgsError] = useState(null); + + const getOrganizations = useCallback(async () => { + try { + setIsLoadingOrgs(true); + setOrgsError(null); + const orgs = await fetchOrganizations(token); + setOrganizations(Array.isArray(orgs) ? orgs : []); + } catch (err) { + setOrgsError('Failed to load organizations.'); + setOrganizations([]); + console.error(err); + } finally { + setIsLoadingOrgs(false); + } + }, [token]); + + useEffect(() => { + getOrganizations(); + }, [getOrganizations]); + + useEffect(() => { + // This effect handles maintaining a valid selection if the list of orgs changes. + // For example, if the currently selected organization is deleted. + if (selectedOrgId && !organizations.some(o => o.id === selectedOrgId)) { + // Fallback to the first organization in the list, or to "All" if no organizations are left. + setSelectedOrgId(organizations.length > 0 ? organizations[0].id : null); + } + }, [organizations, selectedOrgId]); + + const selectedOrg = organizations.find(o => o.id === selectedOrgId); + + return ( +
    +
    +

    Repositories

    + +
    + +
    + ); +}; + +export default RepositoriesPage; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/pages/ResetPasswordPage.tsx b/app/Fe-AI-Decenter/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..ba19472 --- /dev/null +++ b/app/Fe-AI-Decenter/pages/ResetPasswordPage.tsx @@ -0,0 +1,121 @@ +import React, { useState, useEffect } from 'react'; +import { Link, useParams, useNavigate } from 'react-router-dom'; +import { AerugoIcon } from '../components/icons/DockerIcon'; +import Input from '../components/Input'; +import Button from '../components/Button'; +import { resetPassword } from '../services/api'; + +const ResetPasswordPage: React.FC = () => { + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isSuccess, setIsSuccess] = useState(false); + + useEffect(() => { + if (!token) { + setError("Invalid or missing reset token. Please request a new password reset link."); + } + }, [token]); + + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!newPassword || !confirmPassword) { + setError('Please fill in all fields.'); + return; + } + if (newPassword.length < 8) { + setError('Password must be at least 8 characters long.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + if (!token) { + setError("Cannot reset password without a valid token."); + return; + } + + setIsLoading(true); + try { + // This currently uses the placeholder API function + await resetPassword({ token, new_password: newPassword, confirm_password: confirmPassword }); + setIsSuccess(true); + setTimeout(() => { + navigate('/'); + }, 3000); + } catch (err) { + setError("Failed to reset password. The link may have expired."); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + return ( +
    +
    +
    + + + +
    + +
    + {isSuccess ? ( +
    +

    Password Reset Successfully!

    +

    + You can now sign in with your new password. Redirecting you to the sign in page... +

    +
    + ) : ( + <> +

    + Reset Your Password +

    +

    + Enter your new password below. +

    +
    + setNewPassword(e.target.value)} + placeholder="••••••••" + disabled={isLoading || !token} + autoComplete="new-password" + /> + setConfirmPassword(e.target.value)} + placeholder="••••••••" + disabled={isLoading || !token} + autoComplete="new-password" + /> + {error &&

    {error}

    } + +
    + + )} +
    +
    +
    + ); +}; + +export default ResetPasswordPage; diff --git a/app/Fe-AI-Decenter/services/api.ts b/app/Fe-AI-Decenter/services/api.ts new file mode 100644 index 0000000..d2d8324 --- /dev/null +++ b/app/Fe-AI-Decenter/services/api.ts @@ -0,0 +1,216 @@ +import { API_BASE_URL } from '../config'; +import { + Organization, + Repository, + CreateOrganizationRequest, + UpdateOrganizationRequest, + OrganizationMember, + AddMemberRequest, + CreateRepositoryRequest, + User, + OrganizationRole, + ChangePasswordRequest, + ForgotPasswordRequest, + ResetPasswordRequest +} from '../types'; + +// Interface to match the structure of the API response for organizations +interface OrganizationsApiResponse { + organizations: Organization[]; +} + +// Interface for a single organization API response +interface OrganizationDetailsApiResponse { + organization: Organization; +} + +interface OrganizationMembersApiResponse { + members: OrganizationMember[]; +} + +// Interface for repositories API response +interface RepositoriesApiResponse { + repositories: Repository[]; +} + + +class ApiError extends Error { + constructor(message: string, public status: number) { + super(message); + this.name = 'ApiError'; + } +} + +async function fetchWithAuth( + endpoint: string, + token: string, + options: RequestInit = {} +): Promise { + const headers = new Headers(options.headers || {}); + headers.set('Authorization', `Bearer ${token}`); + if (options.method === 'POST' || options.method === 'PUT' || options.method === 'PATCH' || options.method === 'DELETE') { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Failed to read error response'); + console.error(`API Error: ${response.status} ${response.statusText}`, errorText); + throw new ApiError(`Request failed with status ${response.status}`, response.status); + } + + // Handle cases with no response body (e.g., 204 No Content) + if (response.status === 204) { + return null as T; + } + + return response.json() as Promise; +} + +// Placeholder for public fetch calls (no auth token needed) +async function fetchPublic( + endpoint: string, + options: RequestInit = {} +): Promise { + const headers = new Headers(options.headers || {}); + if (options.method === 'POST' || options.method === 'PUT') { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Failed to read error response'); + console.error(`API Error: ${response.status} ${response.statusText}`, errorText); + throw new ApiError(`Request failed with status ${response.status}`, response.status); + } + + if (response.status === 204) { + return null as T; + } + + return response.json() as Promise; +} + + +export const fetchCurrentUser = (token: string): Promise => { + return fetchWithAuth('/api/v1/auth/me', token); +}; + +export const changePassword = (data: ChangePasswordRequest, token: string): Promise => { + return fetchWithAuth('/api/v1/auth/change-password', token, { + method: 'PUT', + body: JSON.stringify(data), + }); +}; + +// Placeholder for requesting a password reset email +export const forgotPassword = (data: ForgotPasswordRequest): Promise => { + // This should eventually be a real API call to a corrected backend + console.log('Simulating sending password reset for', data.email); + return new Promise(resolve => setTimeout(resolve, 1000)); + // Example of real implementation: + // return fetchPublic('/api/v1/auth/request-password-reset', { + // method: 'POST', + // body: JSON.stringify(data), + // }); +}; + +// Placeholder for resetting the password with a token +export const resetPassword = (data: ResetPasswordRequest): Promise => { + // This should eventually be a real API call to a corrected backend + console.log('Simulating resetting password with token', data.token); + return new Promise(resolve => setTimeout(resolve, 1000)); + // Example of real implementation: + // return fetchPublic('/api/v1/auth/reset-password', { + // method: 'POST', + // body: JSON.stringify(data), + // }); +}; + + +export const fetchOrganizations = async (token: string): Promise => { + const data = await fetchWithAuth('/api/v1/organizations', token); + return data?.organizations || []; +}; + +export const fetchOrganizationDetails = async (orgId: number, token: string): Promise => { + const data = await fetchWithAuth(`/api/v1/organizations/${orgId}`, token); + return data.organization; +}; + +export const createOrganization = (data: CreateOrganizationRequest, token: string): Promise => { + return fetchWithAuth('/api/v1/organizations', token, { + method: 'POST', + body: JSON.stringify(data), + }); +}; + +export const updateOrganization = (orgId: number, data: UpdateOrganizationRequest, token: string): Promise => { + return fetchWithAuth(`/api/v1/organizations/${orgId}`, token, { + method: 'PUT', + body: JSON.stringify(data), + }); +}; + +export const deleteOrganization = (orgId: number, token: string): Promise => { + return fetchWithAuth(`/api/v1/organizations/${orgId}`, token, { + method: 'DELETE', + }); +}; + +export const fetchRepositories = async (token: string): Promise => { + const data = await fetchWithAuth(`/api/v1/repos/repositories`, token); + return data?.repositories || []; +}; + +export const fetchRepositoriesByNamespace = async (namespace: string, token: string): Promise => { + const data = await fetchWithAuth(`/api/v1/repos/repositories/${namespace}`, token); + return data || []; +}; + +export const createRepository = (namespace: string, data: CreateRepositoryRequest, token: string): Promise => { + return fetchWithAuth(`/api/v1/repos/${namespace}`, token, { + method: 'POST', + body: JSON.stringify(data), + }); +}; + +export const deleteRepository = (namespace: string, repoName: string, token: string): Promise => { + return fetchWithAuth(`/api/v1/repos/${namespace}/${repoName}`, token, { + method: 'DELETE', + }); +}; + +export const fetchOrganizationMembers = async (orgId: number, token: string): Promise => { + // The API returns an object { members: [...] }, so we extract the array. + const data = await fetchWithAuth(`/api/v1/organizations/${orgId}/members`, token); + return data?.members || []; +}; + +export const addOrganizationMember = (orgId: number, data: AddMemberRequest, token: string): Promise => { + return fetchWithAuth(`/api/v1/organizations/${orgId}/members`, token, { + method: 'POST', + body: JSON.stringify(data), + }); +}; + +export const updateMemberRole = (orgId: number, memberId: number, role: OrganizationRole, token: string): Promise => { + return fetchWithAuth(`/api/v1/organizations/${orgId}/members/${memberId}`, token, { + method: 'PUT', + body: JSON.stringify({ role }), + }); +}; + +export const deleteMember = (orgId: number, memberId: number, token: string): Promise => { + return fetchWithAuth(`/api/v1/organizations/${orgId}/members/${memberId}`, token, { + method: 'DELETE', + }); +}; \ No newline at end of file diff --git a/app/Fe-AI-Decenter/tsconfig.json b/app/Fe-AI-Decenter/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/app/Fe-AI-Decenter/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/app/Fe-AI-Decenter/types.ts b/app/Fe-AI-Decenter/types.ts new file mode 100644 index 0000000..823f4ce --- /dev/null +++ b/app/Fe-AI-Decenter/types.ts @@ -0,0 +1,130 @@ +// Fix: Replaced incorrect file content with proper type definitions. +export enum AuthMode { + Login = 'login', + Register = 'register', +} + +export enum OrganizationRole { + Owner = 'Owner', + Admin = 'Admin', + Member = 'Member', +} + +export interface User { + id: number; + username: string; + email: string; +} + +export interface ChangePasswordRequest { + current_password: string; + new_password: string; + confirm_password: string; +} + +export interface ForgotPasswordRequest { + email: string; +} + +export interface ResetPasswordRequest { + token: string; + new_password: string; + confirm_password: string; +} + +export interface Organization { + id: number; + name: string; + display_name: string; + description: string | null; + avatar_url: string | null; + website_url: string | null; + created_at?: string; + updated_at?: string; +} + +export interface CreateOrganizationRequest { + name: string; + display_name: string; + description: string; + avatar_url?: string; + website_url?: string; +} + +export interface UpdateOrganizationRequest { + display_name: string; + description: string; + avatar_url?: string; + website_url?: string; +} + +export interface OrganizationMember { + id: number; // This is the membership ID + user_id: number; // This is the user's ID + username: string; + email: string; + role: string; +} + +export interface AddMemberRequest { + email: string; + role: OrganizationRole; +} + +export interface Repository { + id: number; + name: string; + description: string | null; + is_public: boolean; + organization: Organization; + organization_id: number; + created_at: string; + updated_at: string; + created_by: number | null; +} + +export interface CreateRepositoryRequest { + name:string; + description: string | null; + is_public: boolean; +} + +export interface ImageLayer { + digest: string; + size: string; +} + +export interface ImageHistoryItem { + command: string; + details: string; +} + +export interface ImageConfig { + created: string; + dockerVersion: string; + osArch: string; + labels: Record; +} + + +export interface ImageTag { + name: string; + digest: string; + osArch: string; + size: string; + pushedAt: string; + // Detailed information for the tag detail view + config: ImageConfig; + layers: ImageLayer[]; + history: ImageHistoryItem[]; +} + +export interface Webhook { + id: number; + url: string; + events: string[]; + lastDelivery?: { + status: 'success' | 'failed'; + timestamp: string; + }; +} \ No newline at end of file diff --git a/app/Fe-AI-Decenter/vite.config.ts b/app/Fe-AI-Decenter/vite.config.ts new file mode 100644 index 0000000..6a667fb --- /dev/null +++ b/app/Fe-AI-Decenter/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, '.', ''); + return { + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173 + }, + define: { + 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + } + } + }; +}); diff --git a/src/bin/production.rs b/src/bin/production.rs index 8561368..c0e9a1f 100644 --- a/src/bin/production.rs +++ b/src/bin/production.rs @@ -119,6 +119,13 @@ async fn main() -> anyhow::Result<()> { info!("✅ S3 storage initialized - bucket: {}", settings.storage.bucket_name()); + // Initialize email service for production + let email_service = Arc::new( + aerugo::email::EmailService::new(settings.email.clone()) + .context("Failed to initialize email service")? + ); + info!("📧 Email service initialized for production"); + // Create application state with production optimizations let app_state = AppState { db_pool: database_pool, @@ -126,6 +133,7 @@ async fn main() -> anyhow::Result<()> { cache: Some(Arc::new(cache)), storage, manifest_cache: Arc::new(RwLock::new(HashMap::new())), + email_service, }; // Create Axum application with optimized routes diff --git a/src/cache.rs b/src/cache.rs index 7785fad..46bba4b 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -890,6 +890,85 @@ impl RegistryCache { Ok(()) } + + /// Cache OTP code for password reset + pub async fn cache_otp_code(&self, email: &str, otp_code: &str, ttl: Duration) -> Result<()> { + if self.config.enable_memory { + let mut cache = self.memory_cache.write().await; + let cache_key = format!("otp:reset:{}", email); + cache.user_session_cache.insert( + cache_key, + CacheEntry::new(UserSessionCache { + user_id: email.to_string(), // Using email as user_id for OTP + last_activity: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(), + session_data: { + let mut map = HashMap::new(); + map.insert("otp_code".to_string(), otp_code.to_string()); + map + }, + }, ttl), + ); + } + + if self.config.enable_redis && self.redis_client.is_some() { + if let Some(redis) = &self.redis_client { + if let Ok(mut conn) = redis.get_connection() { + let redis_key = format!("otp:reset:{}", email); + let _: Result<(), _> = conn.set_ex(&redis_key, otp_code, ttl.as_secs() as u64); + } + } + } + + Ok(()) + } + + /// Get cached OTP code + pub async fn get_otp_code(&self, email: &str) -> Option { + let cache_key = format!("otp:reset:{}", email); + + if self.config.enable_memory { + let cache = self.memory_cache.read().await; + if let Some(entry) = cache.user_session_cache.get(&cache_key) { + if !entry.is_expired() { + if let Some(otp_code) = entry.data.session_data.get("otp_code") { + return Some(otp_code.clone()); + } + } + } + } + + if self.config.enable_redis && self.redis_client.is_some() { + if let Some(redis) = &self.redis_client { + if let Ok(mut conn) = redis.get_connection() { + if let Ok(otp_code) = conn.get::<_, String>(&cache_key) { + return Some(otp_code); + } + } + } + } + + None + } + + /// Remove OTP code (after use) + pub async fn remove_otp_code(&self, email: &str) -> Result<()> { + let cache_key = format!("otp:reset:{}", email); + + if self.config.enable_memory { + let mut cache = self.memory_cache.write().await; + cache.user_session_cache.remove(&cache_key); + } + + if self.config.enable_redis && self.redis_client.is_some() { + if let Some(redis) = &self.redis_client { + if let Ok(mut conn) = redis.get_connection() { + let _: Result<(), _> = conn.del(&cache_key); + } + } + } + + Ok(()) + } } /// Cache statistics diff --git a/src/config/settings.rs b/src/config/settings.rs index 77048df..c03b6c2 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -17,6 +17,8 @@ pub struct Settings { pub cache: CacheSettings, #[validate] pub auth: AuthSettings, + #[validate] + pub email: EmailSettings, } #[derive(Debug, Serialize, Deserialize, Clone, Validate)] @@ -225,6 +227,26 @@ impl Settings { .and_then(|s| s.parse().ok()) .unwrap_or(604800), }, + email: EmailSettings { + smtp_host: std::env::var("SMTP_HOST").unwrap_or_else(|_| "localhost".to_string()), + smtp_port: std::env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(587), + smtp_username: std::env::var("SMTP_USERNAME").unwrap_or_else(|_| "".to_string()), + smtp_password: Secret::new(std::env::var("SMTP_PASSWORD").unwrap_or_else(|_| "".to_string())), + from_email: std::env::var("FROM_EMAIL").unwrap_or_else(|_| "noreply@localhost".to_string()), + from_name: std::env::var("FROM_NAME").unwrap_or_else(|_| "Aerugo Registry".to_string()), + use_tls: std::env::var("SMTP_USE_TLS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(true), + test_mode: std::env::var("EMAIL_TEST_MODE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(cfg!(debug_assertions)), // Use test mode in development by default + test_email_file: std::env::var("EMAIL_TEST_FILE").ok(), + }, }; settings @@ -241,6 +263,7 @@ impl Settings { self.storage.validate()?; self.cache.validate()?; self.auth.validate()?; + self.email.validate()?; Ok(()) } @@ -280,3 +303,17 @@ fn validate_url(url: &str) -> Result<(), validator::ValidationError> { .map(|_| ()) .map_err(|_| validator::ValidationError::new("invalid_url")) } + +#[derive(Debug, Deserialize, Clone, Validate)] +pub struct EmailSettings { + pub smtp_host: String, + pub smtp_port: u16, + pub smtp_username: String, + pub smtp_password: Secret, + pub from_email: String, + pub from_name: String, + pub use_tls: bool, + // For testing environment + pub test_mode: bool, + pub test_email_file: Option, +} diff --git a/src/email.rs b/src/email.rs new file mode 100644 index 0000000..82ba9de --- /dev/null +++ b/src/email.rs @@ -0,0 +1,244 @@ +use crate::config::settings::EmailSettings; +use anyhow::{Context, Result}; +use chrono; +use lettre::message::header::ContentType; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::client::{Tls, TlsParameters}; +use lettre::{Message, SmtpTransport, Transport}; +use secrecy::ExposeSecret; +use tracing::{debug, error, info, warn}; + +#[derive(Clone)] +pub struct EmailService { + settings: EmailSettings, + mailer: Option, +} + +impl EmailService { + pub fn new(settings: EmailSettings) -> Result { + let mailer = if settings.test_mode { + warn!("Email service running in TEST MODE - emails will not be sent via SMTP"); + None + } else { + let creds = Credentials::new( + settings.smtp_username.clone(), + settings.smtp_password.expose_secret().clone(), + ); + + let tls_parameters = TlsParameters::builder(settings.smtp_host.clone()) + .build() + .context("Failed to build TLS parameters")?; + + info!("Configuring SMTP transport for {}:{} with STARTTLS", + settings.smtp_host, settings.smtp_port); + debug!("Using username: {}", settings.smtp_username); + + let mailer = SmtpTransport::relay(&settings.smtp_host)? + .port(settings.smtp_port) + .credentials(creds) + .tls(Tls::Required(tls_parameters)) + .build(); + + Some(mailer) + }; + + Ok(Self { settings, mailer }) + } + + pub async fn send_forgot_password_email( + &self, + to_email: &str, + to_name: &str, + reset_token: &str, + reset_url: &str, + ) -> Result<()> { + let subject = "Reset Your Password - Aerugo Registry"; + let html_body = self.generate_forgot_password_html(to_name, reset_token, reset_url); + let text_body = self.generate_forgot_password_text(to_name, reset_token, reset_url); + + self.send_email(to_email, to_name, subject, &html_body, &text_body) + .await + } + + async fn send_email( + &self, + to_email: &str, + to_name: &str, + subject: &str, + html_body: &str, + text_body: &str, + ) -> Result<()> { + if self.settings.test_mode { + return self.save_test_email(to_email, subject, html_body).await; + } + + let email = Message::builder() + .from( + format!("{} <{}>", self.settings.from_name, self.settings.from_email) + .parse() + .context("Failed to parse from email")?, + ) + .to(format!("{} <{}>", to_name, to_email) + .parse() + .context("Failed to parse to email")?) + .subject(subject) + .multipart( + lettre::message::MultiPart::alternative() + .singlepart( + lettre::message::SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text_body.to_string()), + ) + .singlepart( + lettre::message::SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html_body.to_string()), + ), + ) + .context("Failed to build email message")?; + + if let Some(ref mailer) = self.mailer { + debug!("Attempting to send email to {} via SMTP", to_email); + match mailer.send(&email) { + Ok(response) => { + info!("Email sent successfully to {}: {:?}", to_email, response); + Ok(()) + } + Err(e) => { + error!("Failed to send email to {}: {}", to_email, e); + debug!("SMTP error details: {:?}", e); + Err(anyhow::anyhow!("Failed to send email: {}", e)) + } + } + } else { + error!("SMTP mailer not configured"); + Err(anyhow::anyhow!("SMTP mailer not configured")) + } + } + + async fn save_test_email( + &self, + to_email: &str, + subject: &str, + html_body: &str, + ) -> Result<()> { + let test_content = format!( + "=== TEST EMAIL ===\n\ + To: {}\n\ + Subject: {}\n\ + Date: {}\n\ + \n\ + HTML Body:\n\ + {}\n\ + ==================\n\n", + to_email, + subject, + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), + html_body + ); + + let default_file = "test_emails.log".to_string(); + let file_path = self + .settings + .test_email_file + .as_ref() + .unwrap_or(&default_file); + + tokio::fs::write(file_path, test_content) + .await + .context("Failed to write test email to file")?; + + info!("Test email saved to file: {}", file_path); + Ok(()) + } + + fn generate_forgot_password_html( + &self, + to_name: &str, + reset_token: &str, + reset_url: &str, + ) -> String { + format!( + r#" + + + + + Reset Your Password + + + +
    +
    +

    🔐 Aerugo Registry

    +

    Password Reset Request

    +
    + +

    Hello {}!

    + +

    We received a request to reset your password for your Aerugo Registry account.

    + +

    Your password reset verification code is:

    + +
    +
    + {} +
    +
    + +

    Please enter this 6-digit code in the password reset form to continue.

    + +

    Important:

    +
      +
    • This verification code will expire in 15 minutes
    • +
    • If you didn't request this, you can safely ignore this email
    • +
    • For security reasons, never share this code with anyone
    • +
    + + +
    + +"#, + to_name, reset_token + ) + } + + fn generate_forgot_password_text( + &self, + to_name: &str, + reset_token: &str, + _reset_url: &str, + ) -> String { + format!( + r#"Hello {}! + +We received a request to reset your password for your Aerugo Registry account. + +Your password reset verification code is: + + {} + +Please enter this 6-digit code in the password reset form to continue. + +IMPORTANT: +- This verification code will expire in 15 minutes +- If you didn't request this, you can safely ignore this email +- For security reasons, never share this code with anyone + +© 2025 Aerugo Registry - Decenter.ai +This email was sent from an automated system. Please do not reply."#, + to_name, reset_token + ) + } +} \ No newline at end of file diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 6b61acf..90c43a5 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -60,13 +60,24 @@ pub struct ChangePasswordRequest { #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ForgotPasswordRequest { /// Email address to reset password for - email: String, - /// New password (optional - if provided, will reset immediately) - #[serde(skip_serializing_if = "Option::is_none")] - new_password: Option, - /// Confirmation of new password (required if new_password is provided) - #[serde(skip_serializing_if = "Option::is_none")] - confirm_password: Option, + #[schema(example = "user@example.com")] + pub email: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct VerifyOtpRequest { + /// Email address + #[schema(example = "user@example.com")] + pub email: String, + /// 6-digit OTP code + #[schema(example = "123456")] + pub otp_code: String, + /// New password + #[schema(example = "newpassword123")] + pub new_password: String, + /// Confirm new password + #[schema(example = "newpassword123")] + pub confirm_password: String, } #[derive(Debug, Serialize, Deserialize)] @@ -773,16 +784,15 @@ pub async fn change_password( } } -/// Simplified forgot password handler +/// NEW: Simple forgot password with 6-digit OTP #[utoipa::path( post, path = "/api/v1/auth/forgot-password", tag = "auth", request_body = ForgotPasswordRequest, responses( - (status = 200, description = "Password reset successful or email verification needed"), - (status = 400, description = "Invalid email format or password validation failed"), - (status = 404, description = "Email not found"), + (status = 200, description = "Password reset OTP sent to email"), + (status = 400, description = "Invalid email or user not found"), (status = 500, description = "Internal server error") ) )] @@ -790,122 +800,165 @@ pub async fn forgot_password( State(state): State, Json(req): Json, ) -> impl IntoResponse { - // Validate email format - if !req.email.contains('@') { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "Invalid email format" - })), - ); - } - // Find user by email - let user = match sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", req.email) + let user = match sqlx::query!("SELECT id, username, email FROM users WHERE email = $1", req.email) .fetch_optional(&state.db_pool) .await { Ok(Some(user)) => user, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "Email not found" - })), - ); + return Json(serde_json::json!({ + "error": "Email not found" + })); } - Err(e) => { - tracing::error!("Database error during forgot password: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Internal server error" - })), - ); + Err(_) => { + return Json(serde_json::json!({ + "error": "Internal server error" + })); } }; - // If new password is provided, validate and reset immediately - if let (Some(new_password), Some(confirm_password)) = (&req.new_password, &req.confirm_password) { - // Validate new password - if new_password.len() < 8 { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "New password must be at least 8 characters long" - })), - ); + // Generate 6-digit code + use rand::Rng; + let otp_code: u32 = rand::thread_rng().gen_range(100000..=999999); + let otp_string = otp_code.to_string(); + + // Store OTP in Redis cache with 15 minutes TTL + if let Some(cache) = &state.cache { + if let Err(e) = cache.cache_otp_code(&user.email, &otp_string, std::time::Duration::from_secs(900)).await { + tracing::warn!("Failed to store OTP in cache: {}", e); + return Json(serde_json::json!({ + "error": "Failed to generate OTP code" + })); } + } else { + return Json(serde_json::json!({ + "error": "OTP service not available" + })); + } + + // Send email + match state.email_service.send_forgot_password_email( + &user.email, + &user.username, + &otp_string, + "" + ).await { + Ok(()) => Json(serde_json::json!({ + "message": "Password reset instructions have been sent to your email", + "email_sent": true + })), + Err(_) => Json(serde_json::json!({ + "error": "Failed to send email" + })) + } +} - if new_password != confirm_password { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "New password and confirmation do not match" - })), - ); - } +/// Verify OTP and reset password +#[utoipa::path( + post, + path = "/api/v1/auth/verify-otp", + tag = "auth", + request_body = VerifyOtpRequest, + responses( + (status = 200, description = "Password successfully reset"), + (status = 400, description = "Invalid OTP, passwords don't match, or validation failed"), + (status = 404, description = "OTP expired or does not exist"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn verify_otp_and_reset( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + // Validate passwords match + if req.new_password != req.confirm_password { + return Json(serde_json::json!({ + "error": "Passwords do not match" + })); + } + + // Validate password length + if req.new_password.len() < 8 { + return Json(serde_json::json!({ + "error": "Password must be at least 8 characters long" + })); + } - // Hash the new password - let salt = SaltString::generate(&mut OsRng); - let new_password_hash = match Argon2::default() - .hash_password(new_password.as_bytes(), &salt) - { - Ok(hash) => hash.to_string(), - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Failed to hash new password" - })), - ); - } - }; - - // Update password in database - match sqlx::query!( - "UPDATE users SET password_hash = $1 WHERE id = $2", - new_password_hash, - user.id - ) - .execute(&state.db_pool) + // Find user by email + let user = match sqlx::query!("SELECT id, username, email FROM users WHERE email = $1", req.email) + .fetch_optional(&state.db_pool) .await - { - Ok(_) => { - // Optionally invalidate user sessions in cache - if let Some(cache) = &state.cache { - if let Err(e) = cache.invalidate_user_permissions(&user.id.to_string()).await { - tracing::warn!("Failed to invalidate user permissions in cache: {}", e); - } - } + { + Ok(Some(user)) => user, + Ok(None) => { + return Json(serde_json::json!({ + "error": "Email not found" + })); + } + Err(_) => { + return Json(serde_json::json!({ + "error": "Internal server error" + })); + } + }; - ( - StatusCode::OK, - Json(serde_json::json!({ - "message": "Password successfully reset", - "success": true - })), - ) + // Validate OTP format + if req.otp_code.len() != 6 || !req.otp_code.chars().all(|c| c.is_ascii_digit()) { + return Json(serde_json::json!({ + "error": "Invalid OTP code. Must be 6 digits." + })); + } + + // Verify OTP from Redis cache + if let Some(cache) = &state.cache { + match cache.get_otp_code(&req.email).await { + Some(stored_otp) => { + if stored_otp != req.otp_code { + return Json(serde_json::json!({ + "error": "Invalid OTP code" + })); + } + // OTP is valid, delete it to prevent reuse + let _ = cache.remove_otp_code(&req.email).await; } - Err(e) => { - tracing::error!("Failed to update password: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Failed to update password" - })), - ) + None => { + return Json(serde_json::json!({ + "error": "OTP code has expired or does not exist" + })); } } } else { - // If no password provided, just confirm email exists - ( - StatusCode::OK, - Json(serde_json::json!({ - "message": "Email found. You can now provide a new password.", - "email_verified": true, - "user_id": user.id - })), - ) + return Json(serde_json::json!({ + "error": "OTP verification service not available" + })); } -} + + // Hash new password + use argon2::{Argon2, PasswordHasher}; + use argon2::password_hash::{SaltString, rand_core::OsRng}; + + let salt = SaltString::generate(&mut OsRng); + let password_hash = match Argon2::default().hash_password(req.new_password.as_bytes(), &salt) { + Ok(hash) => hash.to_string(), + Err(_) => { + return Json(serde_json::json!({ + "error": "Failed to hash password" + })); + } + }; + + // Update password in database + match sqlx::query!("UPDATE users SET password_hash = $1 WHERE id = $2", password_hash, user.id) + .execute(&state.db_pool) + .await + { + Ok(_) => Json(serde_json::json!({ + "message": "Password successfully reset", + "success": true + })), + Err(_) => Json(serde_json::json!({ + "error": "Failed to update password" + })) + } +} diff --git a/src/lib.rs b/src/lib.rs index 783aeac..501d25f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod cache; pub mod config; pub mod database; pub mod db; +pub mod email; pub mod handlers; pub mod models; pub mod openapi; @@ -26,6 +27,7 @@ pub struct AppState { pub storage: Arc, pub cache: Option>, pub manifest_cache: Arc>>, // digest -> content + pub email_service: Arc, } // Handler for serving index.html (SPA entry point) diff --git a/src/main.rs b/src/main.rs index 921668a..b560053 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,6 +96,23 @@ async fn main() -> Result<()> { } }; + // Initialize email service + println!("Initializing email service..."); + let email_service = match aerugo::email::EmailService::new(settings.email.clone()) { + Ok(service) => { + if settings.email.test_mode { + println!("Email service initialized in TEST MODE"); + } else { + println!("Email service initialized with SMTP: {}:{}", + settings.email.smtp_host, settings.email.smtp_port); + } + Arc::new(service) + }, + Err(e) => { + return Err(anyhow::anyhow!("Failed to initialize email service: {}", e)); + } + }; + // Create shared application state let state = AppState { db_pool, @@ -103,6 +120,7 @@ async fn main() -> Result<()> { storage, cache, manifest_cache: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), + email_service, }; println!("Application state created successfully"); diff --git a/src/openapi.rs b/src/openapi.rs index 7a92292..3a50ebc 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -52,6 +52,7 @@ impl Modify for SecurityAddon { auth::refresh, auth::change_password, auth::forgot_password, + auth::verify_otp_and_reset, // Organization endpoints organizations::create_organization, @@ -96,6 +97,7 @@ impl Modify for SecurityAddon { auth::AuthResponse, auth::ChangePasswordRequest, auth::ForgotPasswordRequest, + auth::VerifyOtpRequest, // Organization schemas Organization, diff --git a/src/routes/auth.rs b/src/routes/auth.rs index a3e2cf9..c3f84fa 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -14,4 +14,5 @@ pub fn auth_router() -> Router { .route("/refresh", post(auth::refresh)) .route("/change-password", put(auth::change_password)) .route("/forgot-password", post(auth::forgot_password)) + .route("/verify-otp", post(auth::verify_otp_and_reset)) }