This commit is contained in:
Jesper 2024-10-04 11:50:04 +02:00
parent 25606bd438
commit c9c88c5a22
26 changed files with 6831 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View 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
View 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

Binary file not shown.

14
frontend/index.html Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

5327
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

15
frontend/public/vite.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

View 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

View 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
View File

@ -0,0 +1,3 @@
const API_BASE_URL = 'http://localhost:3000';
export default API_BASE_URL;

43
frontend/src/index.tsx Normal file
View 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'));

View 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>
);
}

View 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 */
}
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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;
}
}

View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [preact()],
});