idk
This commit is contained in:
parent
25606bd438
commit
c9c88c5a22
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -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?
|
15
frontend/README.md
Normal file
15
frontend/README.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# `create-preact`
|
||||||
|
|
||||||
|
<h2 align="center">
|
||||||
|
<img height="256" width="256" src="./src/assets/preact.svg">
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<h3 align="center">Get started using Preact and Vite!</h3>
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
- `npm run dev` - Starts a dev server at http://localhost:5173/
|
||||||
|
|
||||||
|
- `npm run build` - Builds for production, emitting to `dist/`
|
||||||
|
|
||||||
|
- `npm run preview` - Starts a server at http://localhost:4173/ to test production build locally
|
BIN
frontend/bun.lockb
Executable file
BIN
frontend/bun.lockb
Executable file
Binary file not shown.
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>PasswordBox</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
frontend/logo.png
Normal file
BIN
frontend/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 230 KiB |
5327
frontend/package-lock.json
generated
Normal file
5327
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.447.0",
|
||||||
|
"preact": "^10.22.1",
|
||||||
|
"preact-iso": "^2.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.9.0",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-preact": "^1.4.0",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.3.3"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "preact"
|
||||||
|
}
|
||||||
|
}
|
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 230 KiB |
15
frontend/public/vite.svg
Normal file
15
frontend/public/vite.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%">
|
||||||
|
<stop offset="0%" stop-color="#41D1FF"></stop>
|
||||||
|
<stop offset="100%" stop-color="#BD34FE"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%">
|
||||||
|
<stop offset="0%" stop-color="#FFEA83"></stop>
|
||||||
|
<stop offset="8.333%" stop-color="#FFDD35"></stop>
|
||||||
|
<stop offset="100%" stop-color="#FFA800"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path>
|
||||||
|
<path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 230 KiB |
6
frontend/src/assets/preact.svg
Normal file
6
frontend/src/assets/preact.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="27.68" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 296">
|
||||||
|
<path fill="#673AB8" d="m128 0l128 73.9v147.8l-128 73.9L0 221.7V73.9z"></path>
|
||||||
|
<path fill="#FFF" d="M34.865 220.478c17.016 21.78 71.095 5.185 122.15-34.704c51.055-39.888 80.24-88.345 63.224-110.126c-17.017-21.78-71.095-5.184-122.15 34.704c-51.055 39.89-80.24 88.346-63.224 110.126Zm7.27-5.68c-5.644-7.222-3.178-21.402 7.573-39.253c11.322-18.797 30.541-39.548 54.06-57.923c23.52-18.375 48.303-32.004 69.281-38.442c19.922-6.113 34.277-5.075 39.92 2.148c5.644 7.223 3.178 21.403-7.573 39.254c-11.322 18.797-30.541 39.547-54.06 57.923c-23.52 18.375-48.304 32.004-69.281 38.441c-19.922 6.114-34.277 5.076-39.92-2.147Z"></path>
|
||||||
|
<path fill="#FFF" d="M220.239 220.478c17.017-21.78-12.169-70.237-63.224-110.126C105.96 70.464 51.88 53.868 34.865 75.648c-17.017 21.78 12.169 70.238 63.224 110.126c51.055 39.889 105.133 56.485 122.15 34.704Zm-7.27-5.68c-5.643 7.224-19.998 8.262-39.92 2.148c-20.978-6.437-45.761-20.066-69.28-38.441c-23.52-18.376-42.74-39.126-54.06-57.923c-10.752-17.851-13.218-32.03-7.575-39.254c5.644-7.223 19.999-8.261 39.92-2.148c20.978 6.438 45.762 20.067 69.281 38.442c23.52 18.375 42.739 39.126 54.06 57.923c10.752 17.85 13.218 32.03 7.574 39.254Z"></path>
|
||||||
|
<path fill="#FFF" d="M127.552 167.667c10.827 0 19.603-8.777 19.603-19.604c0-10.826-8.776-19.603-19.603-19.603c-10.827 0-19.604 8.777-19.604 19.603c0 10.827 8.777 19.604 19.604 19.604Z"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
38
frontend/src/components/Header.tsx
Normal file
38
frontend/src/components/Header.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// src/components/Header.js
|
||||||
|
import { useLocation } from 'preact-iso';
|
||||||
|
import { LogOut } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { url } = useLocation();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('admin'); // Clear admin status
|
||||||
|
window.location.href = '/login'; // Redirect to login after logout
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAdmin = localStorage.getItem('admin') === "true"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="app-header">
|
||||||
|
<nav>
|
||||||
|
<a href="/" className={url === '/' ? 'active' : ''}>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a href="/profile" className={url === '/profile' ? 'active' : ''}>
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
{isAdmin && ( // Conditionally render Admin link if user is an admin
|
||||||
|
<a href="/admin" className={url === '/admin' ? 'active' : ''}>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
{localStorage.getItem('accessToken') && (
|
||||||
|
<button onClick={handleLogout} className="logout-button" aria-label="Logout">
|
||||||
|
<LogOut size={24} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
3
frontend/src/config.js
Normal file
3
frontend/src/config.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const API_BASE_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
export default API_BASE_URL;
|
43
frontend/src/index.tsx
Normal file
43
frontend/src/index.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { render } from 'preact';
|
||||||
|
import { LocationProvider, Router, Route } from 'preact-iso';
|
||||||
|
|
||||||
|
import { Header } from './components/Header.js';
|
||||||
|
import { Home } from './pages/Home/index.js';
|
||||||
|
import { Login } from './pages/Login/index.js';
|
||||||
|
import { Profile } from './pages/Profile/index.js';
|
||||||
|
import { Admin } from './pages/Admin/index.js';
|
||||||
|
import { NotFound } from './pages/_404.js';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const isAuthenticated = !!localStorage.getItem('accessToken');
|
||||||
|
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const showHeader = path !== '/login'
|
||||||
|
if (!isAuthenticated && path != '/login') {
|
||||||
|
window.location.href = "/login"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (isAuthenticated && path == '/login') {
|
||||||
|
window.location.href = "/"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocationProvider>
|
||||||
|
{showHeader && <Header />}
|
||||||
|
<main>
|
||||||
|
<Router>
|
||||||
|
<Route path="/" component={isAuthenticated ? Home : Login} />
|
||||||
|
<Route path="/login" component={isAuthenticated ? Home : Login} />
|
||||||
|
<Route path="/profile" component={isAuthenticated ? Profile : Login} />
|
||||||
|
<Route path="/admin" component={isAuthenticated ? Admin : Login} />
|
||||||
|
<Route default component={NotFound} />
|
||||||
|
</Router>
|
||||||
|
</main>
|
||||||
|
</LocationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('app'));
|
239
frontend/src/pages/Admin/index.tsx
Normal file
239
frontend/src/pages/Admin/index.tsx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
import API_BASE_URL from '../../config';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
export function Admin() {
|
||||||
|
const isAdmin = localStorage.getItem('admin') === "true";
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [newUser, setNewUser] = useState({ username: '', password: '', name: '' });
|
||||||
|
const [newGroupName, setNewGroupName] = useState('');
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState('');
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return <div className="no-access">You do not have permission to view this page.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const usersResponse = await fetch(`${API_BASE_URL}/admin/users`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const usersData = await usersResponse.json();
|
||||||
|
if (!usersResponse.ok) throw new Error('Failed to fetch users');
|
||||||
|
setUsers(usersData);
|
||||||
|
|
||||||
|
const groupsResponse = await fetch(`${API_BASE_URL}/admin/group`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const groupsData = await groupsResponse.json();
|
||||||
|
if (!groupsResponse.ok) throw new Error('Failed to fetch groups');
|
||||||
|
setGroups(groupsData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteUser = async (userId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/user/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to delete user');
|
||||||
|
setUsers(prevUsers => prevUsers.filter(user => user.id !== userId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteGroup = async (groupId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/group/${groupId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to delete group');
|
||||||
|
setGroups(prevGroups => prevGroups.filter(group => group.GroupID !== groupId));
|
||||||
|
if (selectedGroupId === groupId) setSelectedGroupId('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUserToGroup = async (userId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/user/group/${selectedGroupId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ "userId": userId })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to add user to group');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerUser = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(newUser),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to register user');
|
||||||
|
const registeredUser = await response.json();
|
||||||
|
setUsers(prevUsers => [...prevUsers, registeredUser]);
|
||||||
|
setNewUser({ username: '', password: '', name: '' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setError(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createGroup = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/group`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: newGroupName }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to create group');
|
||||||
|
const createdGroup = await response.json();
|
||||||
|
setGroups(prevGroups => [...prevGroups, createdGroup]);
|
||||||
|
setNewGroupName('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setError(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p>Loading...</p>;
|
||||||
|
if (error) return <p>Error: {error}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-dashboard">
|
||||||
|
<h1>Admin Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="section users-section">
|
||||||
|
<h2>Users</h2>
|
||||||
|
<ul>
|
||||||
|
{users.map(user => (
|
||||||
|
<li key={user.id}>
|
||||||
|
{user.name}
|
||||||
|
<button className="delete-btn" onClick={() => deleteUser(user.id)}>Delete</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section groups-section">
|
||||||
|
<h2>Groups</h2>
|
||||||
|
<ul>
|
||||||
|
{groups.map(group => (
|
||||||
|
<li key={group.id}>
|
||||||
|
{group.GroupName}
|
||||||
|
<button className="delete-btn" onClick={() => deleteGroup(group.GroupID)}>Delete</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section add-to-group-section">
|
||||||
|
<h2>Add User to Group</h2>
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const userId = e.target.userId.value;
|
||||||
|
if (selectedGroupId) addUserToGroup(userId);
|
||||||
|
}}>
|
||||||
|
<select name="userId">
|
||||||
|
{users.map(user => (
|
||||||
|
<option key={user.id} value={user.id}>{user.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="groupId"
|
||||||
|
value={selectedGroupId}
|
||||||
|
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select a group</option>
|
||||||
|
{groups.map(group => (
|
||||||
|
<option key={group.id} value={group.id}>{group.GroupName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Add User</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section register-section">
|
||||||
|
<h2>Register New User</h2>
|
||||||
|
<form onSubmit={registerUser}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={newUser.username}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={newUser.password}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
value={newUser.name}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit">Register User</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section create-group-section">
|
||||||
|
<h2>Create New Group</h2>
|
||||||
|
<form onSubmit={createGroup}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Group Name"
|
||||||
|
value={newGroupName}
|
||||||
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit">Create Group</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
116
frontend/src/pages/Admin/style.css
Normal file
116
frontend/src/pages/Admin/style.css
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
.admin-dashboard {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #b682ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flexbox Layout */
|
||||||
|
.grid-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap; /* Wrap the sections into the next row when needed */
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: space-between; /* Distribute the sections horizontally with space between */
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
flex: 1 1 30%; /* Make the section take up approximately 30% of the row */
|
||||||
|
min-width: 280px; /* Ensure each section has a minimum width */
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #b682ff;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List Styles */
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
background-color: #ff5757;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background-color: #ff3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
background-color: #333;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #b682ff;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #9b50ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Adjustments */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.section {
|
||||||
|
flex: 1 1 45%; /* For medium screens, display 2 sections per row */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.section {
|
||||||
|
flex: 1 1 100%; /* For small screens, each section takes full width */
|
||||||
|
}
|
||||||
|
}
|
301
frontend/src/pages/Home/index.tsx
Normal file
301
frontend/src/pages/Home/index.tsx
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
import API_BASE_URL from '../../config';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
export function Home() {
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState(null);
|
||||||
|
const [passwords, setPasswords] = useState([]);
|
||||||
|
const [revealedPasswords, setRevealedPasswords] = useState({});
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [copiedStates, setCopiedStates] = useState({});
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [newPasswordData, setNewPasswordData] = useState({ name: '', password: '', group_id: '' });
|
||||||
|
const [isPasswordRevealed, setIsPasswordRevealed] = useState(false);
|
||||||
|
|
||||||
|
const signOut = () => {
|
||||||
|
localStorage.clear();
|
||||||
|
window.location.href = '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthToken = () => {
|
||||||
|
return localStorage.getItem('accessToken');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPasswords = async () => {
|
||||||
|
if (selectedGroup) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/password/${selectedGroup}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${getAuthToken()}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
signOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const sortedPasswords = data.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setPasswords(sortedPasswords);
|
||||||
|
} else {
|
||||||
|
console.error('Expected an array, but got:', data);
|
||||||
|
setPasswords([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching passwords:', error);
|
||||||
|
setPasswords([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchGroups = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/password`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${getAuthToken()}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
signOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const sortedGroups = data.sort((a, b) => a.GroupName.localeCompare(b.GroupName));
|
||||||
|
setGroups(sortedGroups);
|
||||||
|
if (sortedGroups.length > 0) {
|
||||||
|
setSelectedGroup(sortedGroups[0].GroupID);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Expected an array, but got:', data);
|
||||||
|
setGroups([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching groups:', error);
|
||||||
|
setGroups([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchGroups();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPasswords();
|
||||||
|
}, [selectedGroup]);
|
||||||
|
|
||||||
|
const toggleRevealPassword = (entryId) => {
|
||||||
|
setRevealedPasswords((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[entryId]: !prev[entryId]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (password, entryId) => {
|
||||||
|
navigator.clipboard.writeText(password)
|
||||||
|
.then(() => {
|
||||||
|
setCopiedStates((prev) => ({ ...prev, [entryId]: true }));
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopiedStates((prev) => ({ ...prev, [entryId]: false }));
|
||||||
|
}, 2000);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to copy: ', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredPasswords = passwords.filter(entry =>
|
||||||
|
entry.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreatePasswordClick = () => {
|
||||||
|
setNewPasswordData({ name: '', password: '', group_id: selectedGroup || '' });
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePassword = () => {
|
||||||
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()';
|
||||||
|
let password = '';
|
||||||
|
const length = 18;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
password += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||||
|
}
|
||||||
|
setNewPasswordData((prev) => ({ ...prev, password }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatePasswordSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/password/${newPasswordData.group_id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${getAuthToken()}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newPasswordData.name,
|
||||||
|
password: newPasswordData.password
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to create password');
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowModal(false);
|
||||||
|
setNewPasswordData({ name: '', password: '', group_id: '' });
|
||||||
|
fetchPasswords();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating password:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePasswordVisibility = () => {
|
||||||
|
setIsPasswordRevealed((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home">
|
||||||
|
<select
|
||||||
|
value={selectedGroup || ''}
|
||||||
|
onChange={(e) => setSelectedGroup(Number(e.target.value))}
|
||||||
|
className="group-dropdown"
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<option key={group.GroupID} value={group.GroupID}>
|
||||||
|
{group.GroupName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search passwords..."
|
||||||
|
value={searchTerm}
|
||||||
|
onInput={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="search-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button className="btn create-btn" onClick={handleCreatePasswordClick}>
|
||||||
|
Create Password
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-content">
|
||||||
|
<h2>Create New Password</h2>
|
||||||
|
<form onSubmit={handleCreatePasswordSubmit}>
|
||||||
|
<select
|
||||||
|
value={newPasswordData.group_id}
|
||||||
|
onChange={(e) => setNewPasswordData({ ...newPasswordData, group_id: e.target.value })}
|
||||||
|
className="group-dropdown"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select a Group</option>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<option key={group.GroupID} value={group.GroupID}>
|
||||||
|
{group.GroupName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
value={newPasswordData.name}
|
||||||
|
onInput={(e) => setNewPasswordData({ ...newPasswordData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
className="search-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="password-input-container">
|
||||||
|
<input
|
||||||
|
type={isPasswordRevealed ? "text" : "password"}
|
||||||
|
placeholder="Password"
|
||||||
|
value={newPasswordData.password}
|
||||||
|
onInput={(e) => setNewPasswordData({ ...newPasswordData, password: e.target.value })}
|
||||||
|
required
|
||||||
|
className="search-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn reveal-btn"
|
||||||
|
onClick={togglePasswordVisibility}
|
||||||
|
>
|
||||||
|
{isPasswordRevealed ? 'Hide' : 'Show'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn generate-btn"
|
||||||
|
onClick={generatePassword}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="submit" className="btn submit-btn">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn cancel-btn" onClick={() => setShowModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
{filteredPasswords.length > 0 ? (
|
||||||
|
filteredPasswords.map((entry) => (
|
||||||
|
<div key={entry.id} className="resource">
|
||||||
|
<h3>{entry.name}</h3>
|
||||||
|
<div className="password-details">
|
||||||
|
<p>
|
||||||
|
Password: {revealedPasswords[entry.id] ? entry.password : '●●●●●●●●●●'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn reveal-btn"
|
||||||
|
onClick={() => toggleRevealPassword(entry.id)}
|
||||||
|
>
|
||||||
|
{revealedPasswords[entry.id] ? 'Hide Password' : 'Reveal Password'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn copy-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(entry.password, entry.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copiedStates[entry.id] ? 'Password copied' : 'Copy Password'}
|
||||||
|
</button>
|
||||||
|
<p>Created By: {entry.created_by}</p>
|
||||||
|
<p>Created At: {entry.created_at}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>No passwords available.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
180
frontend/src/pages/Home/style.css
Normal file
180
frontend/src/pages/Home/style.css
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
img {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #673ab8aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home {
|
||||||
|
padding: 1rem; /* Add some padding to the home div */
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dropdown {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px; /* Limit the width of the dropdown */
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px; /* Limit the width of the search input */
|
||||||
|
}
|
||||||
|
|
||||||
|
.home section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
column-gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #222;
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: box-shadow 0.3s ease, border 0.3s ease;
|
||||||
|
cursor: pointer; /* Indicate that the entry is clickable */
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource:hover {
|
||||||
|
border: 1px solid #673ab8; /* Change border color on hover */
|
||||||
|
box-shadow: 0 0 10px rgba(103, 58, 183, 0.5); /* Purple shadow effect */
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-details {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
background-color: #4caf50; /* Green */
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
background-color: #45a049; /* Darker green */
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-btn {
|
||||||
|
background-color: #2196F3; /* Blue */
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-btn:hover {
|
||||||
|
background-color: #1976D2; /* Darker blue */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.home section {
|
||||||
|
margin-top: 5rem;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
row-gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.resource {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #161616;
|
||||||
|
border-color: #444; /* Darker border in dark mode */
|
||||||
|
}
|
||||||
|
.resource:hover {
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6); /* Darker semi-transparent background */
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #1e1e1e; /* Dark background to match the theme */
|
||||||
|
color: #ccc; /* Light text color for contrast */
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #673ab8; /* Purple border for flair */
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 4px 15px rgba(103, 58, 183, 0.5); /* Purple shadow for accent */
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
color: #fff; /* White heading */
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions .btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
background-color: #673ab8;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
background-color: #5a2ca7; /* Slightly darker purple on hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:hover {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dropdown,
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background-color: #2a2a2a; /* Darker input background */
|
||||||
|
color: #ccc; /* Light text color */
|
||||||
|
border: 1px solid #444; /* Subtle border color */
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-dropdown option {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
71
frontend/src/pages/Login/index.tsx
Normal file
71
frontend/src/pages/Login/index.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { useLocation } from 'preact-iso';
|
||||||
|
import API_BASE_URL from '../../config';
|
||||||
|
import passwordboxLogo from '../../assets/logo.png';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleLogin = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status == 401) {
|
||||||
|
throw new Error('Login failed');
|
||||||
|
}
|
||||||
|
if (response.status == 418) {
|
||||||
|
localStorage.setItem('admin', "true");
|
||||||
|
}
|
||||||
|
const data = await response.text();
|
||||||
|
// Save the access token in local storage
|
||||||
|
localStorage.setItem('accessToken', data);
|
||||||
|
// Redirect to the homepage
|
||||||
|
window.location.href = '/'; // This will navigate to the homepage
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="login-container">
|
||||||
|
<img src={passwordboxLogo} alt="PasswordBox logo" height="160" width="160" />
|
||||||
|
<h2>Login</h2>
|
||||||
|
<form onSubmit={handleLogin}>
|
||||||
|
<div>
|
||||||
|
<label>Email:</label>
|
||||||
|
<input
|
||||||
|
type="username"
|
||||||
|
value={username}
|
||||||
|
onInput={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onInput={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p class="error">{error}</p>}
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
115
frontend/src/pages/Login/style.css
Normal file
115
frontend/src/pages/Login/style.css
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
/* Main container for the login form */
|
||||||
|
.login-container {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 4rem auto;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(103, 58, 184, 0.35);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heading styling */
|
||||||
|
.login-container h2 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #673ab8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input fields container */
|
||||||
|
.login-container form div {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels for input fields */
|
||||||
|
.login-container label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #FFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input fields styling */
|
||||||
|
.login-container input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #222;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||||
|
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus state for input fields */
|
||||||
|
.login-container input:focus {
|
||||||
|
border-color: #673ab8;
|
||||||
|
box-shadow: 0 0 8px rgba(103, 58, 184, 0.2);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message styling */
|
||||||
|
.error {
|
||||||
|
color: #e57373;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit button styling */
|
||||||
|
.login-container button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #673ab8;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit button hover effect */
|
||||||
|
.login-container button:hover {
|
||||||
|
background-color: #5e35b1;
|
||||||
|
box-shadow: 0 10px 20px -5px rgba(103, 58, 184, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive styling for smaller screens */
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.login-container {
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.login-container {
|
||||||
|
background-color: #222;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container input {
|
||||||
|
background-color: #333;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container input:focus {
|
||||||
|
border-color: #bb86fc;
|
||||||
|
box-shadow: 0 0 8px rgba(187, 134, 252, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container button {
|
||||||
|
background-color: #bb86fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container button:hover {
|
||||||
|
background-color: #a76eff;
|
||||||
|
box-shadow: 0 10px 20px -5px rgba(187, 134, 252, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ef9a9a;
|
||||||
|
}
|
||||||
|
}
|
118
frontend/src/pages/Profile/index.tsx
Normal file
118
frontend/src/pages/Profile/index.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import './style.css';
|
||||||
|
import API_BASE_URL from '../../config';
|
||||||
|
|
||||||
|
export function Profile() {
|
||||||
|
const [user, setUser] = useState({ name: '' }); // Initialize with an empty name
|
||||||
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||||
|
const [oldPassword, setOldPassword] = useState(''); // For the old password
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|
||||||
|
// Fetch user data on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch user data');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setUser({ name: data.name }); // Set user name from response
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserData();
|
||||||
|
}, []); // Empty dependency array means this runs once on mount
|
||||||
|
|
||||||
|
const handleChangePassword = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
alert("Passwords don't match!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
oldPassword: oldPassword,
|
||||||
|
newPassword: newPassword,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Password change failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Password change requested');
|
||||||
|
// Resetting state
|
||||||
|
setIsChangingPassword(false);
|
||||||
|
setOldPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home">
|
||||||
|
<section>
|
||||||
|
<div className="resource">
|
||||||
|
<h2>Profile Information</h2>
|
||||||
|
<p><strong>Name:</strong> {user.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="resource">
|
||||||
|
<h2>Security</h2>
|
||||||
|
{!isChangingPassword ? (
|
||||||
|
<button onClick={() => setIsChangingPassword(true)}>
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleChangePassword}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Old Password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="New Password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm New Password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
<button type="button" onClick={() => setIsChangingPassword(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
77
frontend/src/pages/Profile/style.css
Normal file
77
frontend/src/pages/Profile/style.css
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
.home {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
color: #222;
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource:hover {
|
||||||
|
border: 1px solid #000;
|
||||||
|
box-shadow: 0 25px 50px -12px #673ab888;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #673ab8;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.home section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.resource {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #161616;
|
||||||
|
}
|
||||||
|
.resource:hover {
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
background-color: #333;
|
||||||
|
color: #ccc;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
}
|
8
frontend/src/pages/_404.tsx
Normal file
8
frontend/src/pages/_404.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export function NotFound() {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>404: Not Found</h1>
|
||||||
|
<p>It's gone :(</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
70
frontend/src/style.css
Normal file
70
frontend/src/style.css
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color: #222;
|
||||||
|
background-color: #ffffff;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background-color: #673ab8;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
header a {
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
header a.active {
|
||||||
|
background-color: #0005;
|
||||||
|
}
|
||||||
|
|
||||||
|
header a:hover {
|
||||||
|
background-color: #0008;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
main {
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"noEmit": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
|
||||||
|
/* Preact Config */
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"paths": {
|
||||||
|
"react": ["./node_modules/preact/compat/"],
|
||||||
|
"react-dom": ["./node_modules/preact/compat/"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["node_modules/vite/client.d.ts", "**/*"]
|
||||||
|
}
|
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user