Compare commits

...

9 Commits

Author SHA1 Message Date
Alex
9472577d5e frontend adapted hooks.server to new refreshcookie 2025-02-19 12:08:30 +01:00
Alex
f180f59546 style: login screen 2025-02-19 12:07:59 +01:00
Alex
0e12286f15 frontend: add search and mailto 2025-02-19 12:07:40 +01:00
Alex
cf037db080 frontend:refactor layout.server 2025-02-19 12:07:27 +01:00
Alex
012a57956a frontend: fix refreshCookie 2025-02-19 12:06:51 +01:00
Alex
6c18accae4 locale 2025-02-19 12:06:30 +01:00
Alex
2b500ca187 css: moved header styles to header component 2025-02-19 12:06:22 +01:00
Alex
afe0a0de54 frontend: add custom background color to inputField 2025-02-19 12:05:43 +01:00
Alex
3b08e49d6f frontend: refactor header 2025-02-19 12:05:20 +01:00
9 changed files with 404 additions and 244 deletions

View File

@@ -1,60 +1,60 @@
import { BASE_API_URI } from "$lib/utils/constants.js";
import { refreshCookie, userDatesFromRFC3339 } from "$lib/utils/helpers";
import { BASE_API_URI } from '$lib/utils/constants.js';
import { refreshCookie, userDatesFromRFC3339 } from '$lib/utils/helpers';
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
console.log("Hook started", event.url.pathname);
if (event.locals.user) {
// if there is already a user in session load page as normal
console.log("user is logged in");
return await resolve(event);
}
console.log('Hook started', event.url.pathname);
if (event.locals.user) {
// if there is already a user in session load page as normal
console.log('user is logged in');
return await resolve(event);
}
// get cookies from browser
const jwt = event.cookies.get("jwt");
// get cookies from browser
const jwt = event.cookies.get('jwt');
if (!jwt) {
// if there is no jwt load page as normal
return await resolve(event);
}
const response = await fetch(`${BASE_API_URI}/backend/users/current`, {
credentials: "include",
headers: {
Cookie: `jwt=${jwt}`,
},
});
if (!response.ok) {
// Clear the invalid JWT cookie
event.cookies.delete("jwt", { path: "/" });
return await resolve(event);
}
if (!jwt) {
// if there is no jwt load page as normal
return await resolve(event);
}
const response = await fetch(`${BASE_API_URI}/backend/users/current`, {
credentials: 'include',
headers: {
Cookie: `jwt=${jwt}`
}
});
if (!response.ok) {
// Clear the invalid JWT cookie
event.cookies.delete('jwt', { path: '/' });
return await resolve(event);
}
const data = await response.json();
const data = await response.json();
// Check if the server sent a new token
const newToken = response.headers.get("Set-Cookie");
refreshCookie(newToken, event);
// Check if the server sent a new token
const newToken = response.headers.get('Set-Cookie');
refreshCookie(newToken, event.cookies);
userDatesFromRFC3339(data.user);
userDatesFromRFC3339(data.user);
const [subscriptionsResponse, licenceCategoriesResponse] = await Promise.all([
fetch(`${BASE_API_URI}/backend/membership/subscriptions`, {
credentials: "include",
headers: { Cookie: `jwt=${jwt}` },
}),
fetch(`${BASE_API_URI}/backend/licence/categories`, {
credentials: "include",
headers: { Cookie: `jwt=${jwt}` },
}),
]);
const [subscriptionsData, licence_categoriesData] = await Promise.all([
subscriptionsResponse.json(),
licenceCategoriesResponse.json(),
]);
event.locals.user = data.user;
event.locals.subscriptions = subscriptionsData.subscriptions;
event.locals.licence_categories = licence_categoriesData.licence_categories;
const [subscriptionsResponse, licenceCategoriesResponse] = await Promise.all([
fetch(`${BASE_API_URI}/backend/membership/subscriptions`, {
credentials: 'include',
headers: { Cookie: `jwt=${jwt}` }
}),
fetch(`${BASE_API_URI}/backend/licence/categories`, {
credentials: 'include',
headers: { Cookie: `jwt=${jwt}` }
})
]);
const [subscriptionsData, licence_categoriesData] = await Promise.all([
subscriptionsResponse.json(),
licenceCategoriesResponse.json()
]);
event.locals.user = data.user;
event.locals.subscriptions = subscriptionsData.subscriptions;
event.locals.licence_categories = licence_categoriesData.licence_categories;
// load page as normal
return await resolve(event);
// load page as normal
return await resolve(event);
}

View File

@@ -1,25 +1,51 @@
<script>
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { applyAction, enhance } from '$app/forms';
import { page } from '$app/stores';
// import Developer from "$lib/img/hero-image.png";
// import Avatar from "$lib/img/TeamAvatar.jpeg";
import { t } from 'svelte-i18n';
import { writable } from 'svelte/store';
let isMobileMenuOpen = false;
/** @type{HTMLDivElement} */
let headerContainer;
onMount(() => {
console.log('Page data in Header:', $page);
document.documentElement.setAttribute('data-theme', $theme);
document.addEventListener('click', handleClickOutside);
});
onDestroy(() => {
document.removeEventListener('click', handleClickOutside);
});
$: {
console.log('Page data updated:', $page);
}
// Create a theme store
const theme = writable(
typeof window !== 'undefined' ? localStorage.getItem('theme') || 'dark' : 'dark'
);
// Update theme and localStorage when changed
/**
* handle a click outside the menu to close it.
* @param {MouseEvent} event
*/
function handleClickOutside(event) {
if (
isMobileMenuOpen &&
event.target instanceof Node &&
!!headerContainer.contains(event.target)
) {
isMobileMenuOpen = false;
}
}
function toggleMobileMenu() {
isMobileMenuOpen = !isMobileMenuOpen;
}
function toggleTheme() {
theme.update((current) => {
const newTheme = current === 'dark' ? 'bright' : 'dark';
@@ -31,7 +57,7 @@
</script>
<header class="header">
<div class="header-container">
<div class="header-container" bind:this={headerContainer}>
<div class="header-left">
<div class="header-crafted-by-container">
<!-- <a href="https://tiny-bits.net/">
@@ -42,7 +68,17 @@
<!-- </a> -->
</div>
</div>
<div class="header-right">
<div class="mobile-menu-container">
<button
class="mobile-menu-toggle"
on:click={toggleMobileMenu}
aria-label="Toggle menu"
aria-expanded={isMobileMenuOpen}
>
<i class="fas {isMobileMenuOpen ? 'fa-times' : 'fa-bars'}"></i>
</button>
</div>
<div class="header-right" class:mobile-menu-open={isMobileMenuOpen}>
<div class="header-nav-item" class:active={$page.url.pathname === '/'}>
<a href="/">home</a>
</div>
@@ -188,4 +224,172 @@
input:checked + .slider .fa-moon {
opacity: 0;
}
.mobile-menu-toggle {
display: none;
background: none;
border: none;
color: var(--text);
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
}
.mobile-menu-container {
display: none;
}
.header {
position: fixed;
top: 0;
left: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
padding: 3em 0 0;
background: var(--base);
}
.header .header-container {
width: 100%;
max-width: calc(1200px + 10em);
height: 5em;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto;
background-color: var(--base);
}
.header .header-container .header-left {
display: flex;
flex-grow: 1;
}
.header .header-container .header-left .header-crafted-by-container {
font-size: 18px;
font-weight: 300;
}
.header .header-container .header-right {
flex-grow: 1;
justify-content: space-between;
letter-spacing: 1px;
font-weight: 500;
}
.header .header-container .header-right .header-nav-item {
text-transform: uppercase;
margin-left: 10px;
}
.header .header-container .header-right .header-nav-item button {
all: unset;
cursor: pointer;
}
.header .header-container .header-right .header-nav-item a,
.header .header-container .header-right .header-nav-item button {
transition: color 0.3s ease-in-out;
display: block;
padding: 20px 0;
border: none;
color: var(--subtext0);
}
.header .header-container .header-right .header-nav-item:hover a,
.header .header-container .header-right .header-nav-item:hover button {
color: var(--lavender);
}
@media (min-width: 768px) {
.header {
padding: 3em 5rem 0;
}
.header .header-container {
flex-direction: row;
}
.header .header-container .header-right {
display: flex;
justify-content: flex-end;
}
.header .header-container .header-right .header-nav-item {
margin-left: 26px;
}
}
@media (max-width: 768px) {
.mobile-menu-container {
display: block;
position: relative;
}
.header {
padding: 1rem 0;
}
.mobile-menu-toggle {
display: block;
position: absolute;
top: 1rem;
right: 1rem;
z-index: 2;
}
.header .header-container {
z-index: 1;
position: relative;
padding: 0 1rem;
flex-direction: column;
align-items: stretch;
height: auto;
padding: 1rem;
}
.header-right {
display: none;
top: 4rem;
left: 0;
right: 0;
background: var(--base);
padding: 1rem;
border-bottom: 1px solid var(--surface1);
}
.header-right.mobile-menu-open {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.header-nav-item {
width: 100%;
text-align: center;
margin: 0;
padding: 0.5rem 0;
border-bottom: 1px solid var(--surface1);
}
.header-nav-item:last-child {
border-bottom: none;
}
.theme-toggle {
margin: 1rem 0;
}
.header .header-container .header-right {
flex-direction: column;
align-items: stretch;
}
.header .header-container .header-right .header-nav-item {
margin: 0;
text-align: center;
border-top: 1px solid var(--surface1);
}
.header .header-container .header-right .header-nav-item a,
.header .header-container .header-right .header-nav-item button {
padding: 1rem 0;
}
.theme-toggle {
display: flex;
justify-content: center;
margin: 1rem 0;
}
}
</style>

View File

@@ -37,6 +37,9 @@
/** @type {boolean} */
export let readonly = false;
/** @type {string} */
export let backgroundColor = '--surface0'; // New prop for background color
/**
* @param {Event} event - The input event
*/
@@ -110,7 +113,10 @@
$: selectedColor = selectedOption ? selectedOption.color : '';
</script>
<div class="input-box {type === 'checkbox' ? 'checkbox-container' : ''}">
<div
class="input-box {type === 'checkbox' ? 'checkbox-container' : ''}"
style="background-color: var({backgroundColor});"
>
{#if type === 'checkbox'}
<label class="form-control {readonly ? 'form-control--disabled' : ''}">
<input

View File

@@ -204,110 +204,6 @@ li strong {
}
}
.header {
position: fixed;
top: 0;
left: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
padding: 3em 0 0;
background: var(--base);
}
.header.top-banner-open {
margin-top: 5px;
transition: all 0.2s linear;
}
.header .header-container {
width: 100%;
max-width: calc(1200px + 10em);
height: 5em;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto;
background-color: var(--base);
}
.header .header-container .header-left {
display: flex;
flex-grow: 1;
}
.header .header-container .header-left .header-crafted-by-container {
font-size: 18px;
font-weight: 300;
}
.header .header-container .header-left .header-crafted-by-container a {
display: flex;
color: var(--subtext0);
border: none;
}
.header .header-container .header-left .header-crafted-by-container a img {
height: 28px;
}
.header .header-container .header-left .header-crafted-by-container a span {
display: inline-block;
margin: 2px 1ch 0 0;
}
.header .header-container .header-left .header-crafted-by-container .auth0 {
margin-left: 1ch;
color: var(--text);
font-weight: bold;
}
.header .header-container .header-right {
display: flex;
flex-grow: 1;
justify-content: space-between;
letter-spacing: 1px;
font-weight: 500;
}
.header .header-container .header-right .header-nav-item {
text-transform: uppercase;
margin-left: 10px;
}
.header .header-container .header-right .header-nav-item button {
all: unset;
cursor: pointer;
}
.header .header-container .header-right .header-nav-item.active a,
.header .header-container .header-right .header-nav-item.active button {
color: var(--text);
}
.header .header-container .header-right a img {
margin-top: -0.4rem;
height: 28px;
}
.header .header-container .header-right .header-nav-item a,
.header .header-container .header-right .header-nav-item button {
transition: color 0.3s ease-in-out;
display: block;
padding: 20px 0;
border: none;
color: var(--subtext0);
}
.header .header-container .header-right .header-nav-item:hover a,
.header .header-container .header-right .header-nav-item:hover button {
color: var(--lavender);
}
@media (min-width: 680px) {
.header {
padding: 3em 5rem 0;
}
.header.top-banner-open {
margin-top: 48px;
}
.header .header-container {
flex-direction: row;
}
.header .header-container .header-right {
justify-content: flex-end;
}
.header .header-container .header-right .header-nav-item {
margin-left: 26px;
}
}
.button-dark {
transition:
border-color 0.3s ease-in-out,

View File

@@ -35,7 +35,8 @@ export default {
issuing_country: 'Ausstellendes Land',
subscription_name: 'Name des Tarifmodells',
subscription_details: 'Beschreibe das Tarifmodell...',
subscription_conditions: 'Beschreibe die Bedingungen zur Nutzung...'
subscription_conditions: 'Beschreibe die Bedingungen zur Nutzung...',
search: 'Suchen...'
},
validation: {
required: 'Eingabe benötigt',
@@ -137,6 +138,7 @@ export default {
actions: 'Aktionen',
edit: 'Bearbeiten',
delete: 'Löschen',
search: 'Suche:',
mandate_date_signed: 'Mandatserteilungsdatum',
licence_categories: 'Führerscheinklassen',
subscription_model: 'Mitgliedschatfsmodell',

View File

@@ -184,13 +184,13 @@ export function formatError(obj) {
/**
*
* @param {string | null} newToken - The new token for the cookie to set
* @param {import('@sveltejs/kit').RequestEvent } event - The event object
* @param {import('@sveltejs/kit').Cookies } cookies - The event object
*/
export function refreshCookie(newToken, event) {
export function refreshCookie(newToken, cookies) {
if (newToken) {
const match = newToken.match(/jwt=([^;]+)/);
if (match) {
event.cookies.set('jwt', match[1], {
cookies.set('jwt', match[1], {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Secure in production
@@ -200,36 +200,3 @@ export function refreshCookie(newToken, event) {
}
}
}
/**
* Creates a debounced version of an input event handler.
*
* @param {HTMLElement} element - The HTML element to attach the debounced event to.
* @param {number} duration - The delay in milliseconds before the event is triggered after the last input.
* @returns {Object} - An object with a `destroy` method to clean up the event listener.
*
* @example
* <input use:debounce={300} on:debouncedinput={handleInput} />
*/
export function debounce(element, duration) {
/** @type{NodeJS.Timeout} */
let timer;
/**
*
* @param {CustomEventInit} e
*/
function input(e) {
clearTimeout(timer);
timer = setTimeout(() => {
element.dispatchEvent(new CustomEvent('debouncedinput', e));
}, duration);
}
element.addEventListener('input', input);
return {
destroy() {
element.removeEventListener('input', input);
}
};
}

View File

@@ -1,48 +1,48 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { redirect } from "@sveltejs/kit";
import { userDatesFromRFC3339, refreshCookie } from "$lib/utils/helpers";
import { BASE_API_URI } from '$lib/utils/constants';
import { redirect } from '@sveltejs/kit';
import { userDatesFromRFC3339, refreshCookie } from '$lib/utils/helpers';
/** @type {import('./$types').LayoutServerLoad} */
export async function load({ cookies, fetch, locals }) {
const jwt = cookies.get("jwt");
try {
// Fetch user data, subscriptions, and licence categories in parallel
const response = await fetch(`${BASE_API_URI}/backend/users/all`, {
credentials: "include",
headers: {
Cookie: `jwt=${jwt}`,
},
});
if (!response.ok) {
// Clear the invalid JWT cookie
cookies.delete("jwt", { path: "/" });
throw redirect(302, "/auth/login?next=/");
}
const jwt = cookies.get('jwt');
try {
// Fetch user data, subscriptions, and licence categories in parallel
const response = await fetch(`${BASE_API_URI}/backend/users/all`, {
credentials: 'include',
headers: {
Cookie: `jwt=${jwt}`
}
});
if (!response.ok) {
// Clear the invalid JWT cookie
cookies.delete('jwt', { path: '/' });
throw redirect(302, '/auth/login?next=/');
}
const data = await response.json();
// Check if the server sent a new token
const newToken = response.headers.get("Set-Cookie");
refreshCookie(newToken, null);
const data = await response.json();
// Check if the server sent a new token
const newToken = response.headers.get('Set-Cookie');
refreshCookie(newToken, cookies);
/** @type {App.Locals['users']}*/
const users = data.users;
/** @type {App.Locals['users']}*/
const users = data.users;
users.forEach((user) => {
userDatesFromRFC3339(user);
});
users.forEach((user) => {
userDatesFromRFC3339(user);
});
locals.users = users;
return {
subscriptions: locals.subscriptions,
licence_categories: locals.licence_categories,
users: locals.users,
user: locals.user,
};
} catch (error) {
console.error("Error fetching data:", error);
// In case of any error, clear the JWT cookie
cookies.delete("jwt", { path: "/" });
locals.users = users;
return {
subscriptions: locals.subscriptions,
licence_categories: locals.licence_categories,
users: locals.users,
user: locals.user
};
} catch (error) {
console.error('Error fetching data:', error);
// In case of any error, clear the JWT cookie
cookies.delete('jwt', { path: '/' });
throw redirect(302, "/auth/login?next=/");
}
throw redirect(302, '/auth/login?next=/');
}
}

View File

@@ -2,6 +2,7 @@
import Modal from '$lib/components/Modal.svelte';
import UserEditForm from '$lib/components/UserEditForm.svelte';
import SubscriptionEditForm from '$lib/components/SubscriptionEditForm.svelte';
import InputField from '$lib/components/InputField.svelte';
import { t } from 'svelte-i18n';
import { page } from '$app/stores';
import { applyAction, enhance } from '$app/forms';
@@ -25,6 +26,59 @@
let selectedSubscription = null;
let showSubscriptionModal = false;
let showUserModal = false;
let searchTerm = '';
$: filteredUsers = searchTerm ? getFilteredUsers() : users;
function handleMailButtonClick() {
const subject = 'Important Announcement';
const body = `Hello everyone,\n\nThis is an important message.`;
const bccEmails = filteredUsers
.map((/** @type{App.Locals['user']}*/ user) => user.email)
.join(',');
const encodedSubject = encodeURIComponent(subject);
const encodedBody = encodeURIComponent(body);
const mailtoLink = `mailto:?bcc=${bccEmails}&subject=${encodedSubject}&body=${encodedBody}`;
window.location.href = mailtoLink; // Open the mail client
}
/**
* returns a set of users depending on the entered search query
* @return {App.Locals['user'][]}*/
const getFilteredUsers = () => {
if (!searchTerm.trim()) return users;
const term = searchTerm.trim().toLowerCase();
return users.filter((/** @type{App.Locals['user']}*/ user) => {
const basicMatch = [
user.first_name?.toLowerCase(),
user.last_name?.toLowerCase(),
user.email?.toLowerCase(),
user.address?.toLowerCase(),
user.city?.toLowerCase(),
user.dateofbirth?.toLowerCase(),
user.phone?.toLowerCase(),
user.company?.toLowerCase(),
user.licence?.number?.toLowerCase()
].some((field) => field?.includes(term));
const subscriptionMatch = user.membership?.subscription_model?.name
?.toLowerCase()
.includes(term);
const licenceCategoryMatch = user.licence?.categories?.some((cat) =>
cat.category.toLowerCase().includes(term)
);
const addressMatch = [
user.address?.toLowerCase(),
user.zip_code?.toLowerCase(),
user.city?.toLowerCase()
].some((field) => field?.includes(term));
return basicMatch || subscriptionMatch || licenceCategoryMatch || addressMatch;
});
};
/**
* Opens the edit modal for the selected user.
@@ -50,7 +104,7 @@
selectedUser = null;
selectedSubscription = null;
if (form) {
form.errors = undefined;
form.errors = [];
}
};
@@ -117,13 +171,32 @@
{#if activeSection === 'users'}
<div class="section-header">
<h2>{$t('users')}</h2>
<button class="btn primary" on:click={() => openEditUserModal(null)}>
<i class="fas fa-plus"></i>
{$t('add_new')}
</button>
<div class="title-container">
<InputField
name="search"
placeholder={$t('placeholder.search')}
backgroundColor="--base"
/>
</div>
<!-- <input type="text" bind:value={searchTerm} placeholder={$t('placeholder.search')} /> -->
<div>
<button
class="btn primary"
aria-label="Mail Users"
on:click={() => handleMailButtonClick()}
>
<i class="fas fa-envelope"></i>
</button>
</div>
<div>
<button class="btn primary" on:click={() => openEditUserModal(null)}>
<i class="fas fa-plus"></i>
{$t('add_new')}
</button>
</div>
</div>
<div class="accordion">
{#each users as user}
{#each filteredUsers as user}
<details class="accordion-item">
<summary class="accordion-header">
{user.first_name}
@@ -352,6 +425,18 @@
{/if}
<style>
.title-container {
margin: 0 1rem;
flex-grow: 1;
max-width: 500px;
}
.section-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.container {
width: 100%;
height: 100%;
@@ -369,7 +454,6 @@
.sidebar {
width: 250px;
min-height: 600px;
background: var(--surface0);
border-right: 1px solid var(--surface1);
}

View File

@@ -69,6 +69,7 @@
align-items: flex-end;
width: 100%;
max-width: 444px;
margin-top: 30px;
}
.forgot-password {