frontend: initial commit

This commit is contained in:
$(pass /github/name)
2024-09-07 13:36:15 +02:00
parent 4d6938de96
commit 147b8c0afd
22 changed files with 1804 additions and 0 deletions

12
frontend/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Frontend
## Run locally
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```

64
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,64 @@
// See https://kit.svelte.dev/docs/types#app
interface Subscription {
id: string;
name: string;
details: string;
conditions: string | null;
monthly_fee: number;
hourly_rate: number;
included_hours_per_year: number | null;
included_hours_per_month: number | null;
}
interface Membership {
id: string;
status: string;
start_date: string;
end_date: string;
parent_member_id: number | null;
subscription_model: Subscription | null;
}
interface BankAccount {
id: string;
mandate_date_signed: string;
bank: string;
account_holder_name: string;
iban: string;
bic: string;
mandate_reference: string;
}
interface User {
email: string;
first_name: string;
last_name: string;
phone: string | null;
notes: string;
address: string;
zip_code: string;
city: string;
status: number;
id: string;
role_id: number;
date_of_birth: string;
membership: Membership;
bank_account: BankAccount;
company: string | null;
profile_picture: string | null;
payment_status: number;
}
declare global {
namespace App {
// interface Error {}
interface Locals {
user: User;
}
// interface PageData {}
// interface Platform {}
}
}
export {};

18
frontend/src/app.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Poiret+One&family=Quicksand:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,58 @@
import { BASE_API_URI } from "$lib/utils/constants.js";
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
if (event.locals.user) {
// if there is already a user in session load page as normal
return await resolve(event);
}
// 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}/users/backend/current-user`, {
credentials: "include",
headers: {
Cookie: `jwt=${jwt}`,
},
});
if (!response.ok) {
// Clear the invalid JWT cookie
event.cookies.delete("jwt", { path: "/" });
return await resolve(event);
}
// find the user based on the jwt
const userData = await response.json();
event.locals.user = userData;
// event.locals.user = await response.json();
if (event.locals.user.date_of_birth) {
event.locals.user.date_of_birth =
event.locals.user.date_of_birth.split("T")[0];
}
if (event.locals.user.membership) {
if (event.locals.user.membership.start_date) {
event.locals.user.membership.start_date =
event.locals.user.membership.start_date.split("T")[0];
}
if (event.locals.user.membership.end_date) {
event.locals.user.membership.end_date =
event.locals.user.membership.end_date.split("T")[0];
}
}
if (
event.locals.user.bank_account &&
event.locals.user.bank_account.mandate_date_signed
) {
event.locals.user.bank_account.mandate_date_signed =
event.locals.user.bank_account.mandate_date_signed.split("T")[0];
}
// load page as normal
return await resolve(event);
}

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

View File

@@ -0,0 +1,24 @@
<script>
// import Developer from "$lib/img/hero-image.png";
const year = new Date().getFullYear();
</script>
<footer class="footer-container">
<div class="footer-branding-container">
<div class="footer-branding">
<a class="footer-crafted-by-container" href="https://github.com/Sirneij">
<span>Developed by</span>
<!-- <img
class="footer-branded-crafted-img"
src={Developer}
alt="Alexander Stölting"
/> -->
</a>
<span class="footer-copyright"
>&copy; {year} Alexander Stölting. All Rights Reserved.</span
>
</div>
</div>
</footer>

View File

@@ -0,0 +1,79 @@
<script>
import { onMount } 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.png";
onMount(() => {
console.log("Page data in Header:", $page);
});
$: {
console.log("Page data updated:", $page);
}
</script>
<header class="header">
<div class="header-container">
<div class="header-left">
<div class="header-crafted-by-container">
<!-- <a href="https://tiny-bits.net/">
<span>Developed by</span><img
src={Developer}
alt="Alexander Stölting"
/> -->
<!-- </a> -->
</div>
</div>
<div class="header-right">
<div class="header-nav-item" class:active={$page.url.pathname === "/"}>
<a href="/">home</a>
</div>
{#if !$page.data.user}
<div
class="header-nav-item"
class:active={$page.url.pathname === "/auth/login"}
>
<a href="/auth/login">login</a>
</div>
<!-- <div
class="header-nav-item"
class:active={$page.url.pathname === "/auth/register"}
>
<a href="/auth/register">register</a>
</div> -->
{:else}
<div class="header-nav-item">
<a href="/auth/about/{$page.data.user.id}">
<img
src={$page.data.user.thumbnail
? $page.data.user.thumbnail
: Avatar}
alt={`${$page.data.user.first_name} ${$page.data.user.last_name}`}
/>
</a>
</div>
<!-- {#if $page.data.user.is_superuser}
<div
class="header-nav-item"
class:active={$page.url.pathname.startsWith("/auth/admin")}
>
<a href="/auth/admin">admin</a>
</div>
{/if} -->
<form
class="header-nav-item"
action="/auth/logout"
method="POST"
use:enhance={async () => {
return async ({ result }) => {
await applyAction(result);
};
}}
>
<button type="submit">logout</button>
</form>
{/if}
</div>
</div>
</header>

View File

@@ -0,0 +1,14 @@
<script>
import { slide } from "svelte/transition";
/** @type {string} */
export let key;
/** @type {number} */
export let duration = 300;
</script>
{#key key}
<div in:slide={{ duration, delay: duration }} out:slide={{ duration }}>
<slot />
</div>
{/key}

View File

@@ -0,0 +1,521 @@
@font-face {
font-family: "Roboto Mono";
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_gPq_ROW9.ttf)
format("truetype");
}
@font-face {
font-family: "Roboto Mono";
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW9.ttf)
format("truetype");
}
html {
padding: 0 30px;
background-color: black;
color: #9b9b9b;
font-family: "Quicksand", sans-serif;
font-size: 16px;
font-weight: normal;
}
body {
max-width: 1200px;
margin: 5em auto 0 auto;
}
pre,
code {
display: inline;
font-family: "Roboto Mono", monospace;
font-size: 16px;
}
input {
font-family: "Roboto Mono", monospace;
color: white;
border-style: none;
height: 21px;
font-size: 16px;
}
button {
font-size: 16px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: normal;
}
h2 {
margin: 0 0 45px 0;
color: #fff;
font-size: 36px;
}
h3 {
margin: 0 0 2rem 0;
color: #fff;
font-size: 32px;
}
p {
margin: 0 0 45px;
line-height: 1.8;
}
ul {
margin: 0 0 32px;
}
a {
transition: border 0.2s ease-in-out;
border-bottom: 1px solid transparent;
text-decoration: none;
color: #00b7ef;
}
a:hover {
border-bottom-color: #00b7ef;
}
li {
line-height: 1.8;
}
li strong {
color: #fff;
}
.image {
width: 100%;
margin: 0 0 32px;
padding: 0;
}
.image img {
width: 100%;
}
.optanon-alert-box-wrapper {
left: 0;
}
.hidden {
display: none !important;
}
.hide-mobile {
display: none;
}
@media (min-width: 680px) {
body {
margin: 8em auto 0 auto;
}
.hide-mobile {
display: initial;
}
}
.header {
position: fixed;
top: 0;
left: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
padding: 3em 0 0;
background: black;
}
.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;
}
.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: #9b9b9b;
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: #fff;
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: #fff;
}
.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: #9b9b9b;
}
.header .header-container .header-right .header-nav-item:hover a,
.header .header-container .header-right .header-nav-item:hover button {
color: #fdfff5;
}
@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,
background-color 0.3s ease-in-out;
color: white;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: transparent;
border: 1px solid #595b5c;
}
.button-dark:hover {
border-color: #fff;
}
.button-colorful {
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: white;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #d43aff;
border: 1px solid #d43aff;
}
.button-colorful:hover {
background-color: #c907ff;
border-color: #c907ff;
}
.button-orange {
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: white;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #eb5424;
border: 1px solid #eb5424;
}
.button-orange:hover {
background-color: #ca3f12;
border-color: #ca3f12;
}
.button-colorful:disabled {
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: white;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #9a9a9a;
border: 1px solid #9a9a9a;
}
.hero-container {
max-width: 795px;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto 70px auto;
}
.hero-container .hero-logo {
margin-top: 88px;
margin-bottom: 32px;
}
.hero-container .hero-subtitle {
font-size: 20px;
text-align: center;
line-height: 32px;
margin: 0 0 45px 0;
}
.hero-container .hero-buttons-container {
display: flex;
}
.hero-container .hero-buttons-container button {
margin: 0 8px;
}
@media (min-width: 680px) {
.hero-container {
margin: 0 auto 140px auto;
}
}
.container {
transition: opacity 0.2s ease-in-out;
color: white;
letter-spacing: 0;
opacity: 1;
}
.container .content {
width: 100%;
flex-grow: 1;
}
.container .content .step-title {
color: #fff;
font-size: 20px;
font-weight: 500;
line-height: 86px;
opacity: 1;
}
.container .content .step-subtitle {
position: relative;
top: -5px;
font-size: 16px;
font-weight: 300;
}
@media (max-width: 680px) {
.container .content {
margin-top: 120px;
}
}
@media (min-width: 680px) {
.container {
position: relative;
left: 100px;
display: flex;
width: calc(100% - 100px);
padding: 0;
}
.container .content {
max-width: 795px;
padding-left: 116px;
}
.container .content .step-title {
font-size: 36px;
}
}
.input-box {
display: flex;
align-items: center;
margin: 10px 0;
padding: 0 20px;
width: 100%;
height: 73px;
box-sizing: border-box;
background-color: #2f2f2f;
border-radius: 3px;
font-family: "Roboto Mono", monospace;
font-size: 13px;
}
.input-box .label {
text-transform: lowercase;
margin: 0 1ch 0 0;
}
.input-box .input {
background-color: #494848;
border-radius: 6px;
outline: none;
border: 3px solid #494848;
width: 100%;
max-width: 444px;
font-size: 13px;
}
@media (min-width: 680px) {
.input-box {
padding: 0 30px;
margin: 32px 0;
}
}
.btn-container {
display: flex;
justify-content: space-between;
align-items: first baseline;
}
@media (max-width: 680px) {
.btn-container {
align-items: flex-start;
}
.btn-container p {
margin-left: 1rem;
}
}
.warning {
margin: 20px 0;
padding: 1rem;
width: 100%;
box-sizing: border-box;
background-color: rgb(255 228 230);
border: 1px solid rgb(225 29 72);
border-radius: 6px;
color: rgb(225 29 72);
font-size: 16px;
}
.warning a {
color: rgb(225 29 72);
text-decoration: underline;
}
.warning.hidden {
display: none;
}
.error {
margin-top: 10rem;
padding: 30px 40px;
background: #2f3132;
color: #fff;
}
.error p {
margin: 0 0 1rem;
}
.error p.intro {
font-size: 1.3rem;
}
.error .button-colorful {
display: inline-block;
}
@media (min-width: 680px) {
.error {
padding: 65px 80px;
}
}
.footer-branding-container {
color: white;
font-weight: 300;
margin-bottom: 73px;
}
.footer-branding-container .footer-branding {
display: flex;
width: 400px;
}
.footer-branding-container .footer-branding {
flex-direction: column;
text-align: center;
margin: 30px 0 0;
}
.footer-branding-container .footer-branding .footer-crafted-by-container {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: white;
}
.footer-branding-container .footer-branding .footer-crafted-by-container span {
display: inline-block;
margin: 3px 1ch 0 0;
}
.footer-branding-container
.footer-branding
.footer-crafted-by-container
.footer-branded-crafted-img {
height: 28px;
}
.footer-branding-container .footer-branding .footer-copyright {
color: #696969;
letter-spacing: 0.5px;
}
.footer-container {
width: 100%;
color: white;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
}
@media (min-width: 680px) {
.footer-container {
padding: 0;
}
}
.simple-loader {
--b: 20px; /* border thickness */
--n: 15; /* number of dashes*/
--g: 7deg; /* gap between dashes*/
--c: #d43aff; /* the color */
width: 40px; /* size */
aspect-ratio: 1;
border-radius: 50%;
padding: 1px; /* get rid of bad outlines */
background: conic-gradient(#0000, var(--c)) content-box;
--_m: /* we use +/-1deg between colors to avoid jagged edges */ repeating-conic-gradient(
#0000 0deg,
#000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
#0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
),
radial-gradient(
farthest-side,
#0000 calc(98% - var(--b)),
#000 calc(100% - var(--b))
);
-webkit-mask: var(--_m);
mask: var(--_m);
-webkit-mask-composite: destination-in;
mask-composite: intersect;
animation: load 1s infinite steps(var(--n));
}
@keyframes load {
to {
transform: rotate(1turn);
}
}

505
frontend/src/lib/css/styles.min.css vendored Normal file
View File

@@ -0,0 +1,505 @@
@font-face {
font-family: "Roboto Mono";
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_gPq_ROW9.ttf)
format("truetype");
}
@font-face {
font-family: "Roboto Mono";
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW9.ttf)
format("truetype");
}
html {
padding: 0 30px;
background-color: #000;
color: #9b9b9b;
font-family: Quicksand, sans-serif;
font-size: 16px;
font-weight: 400;
}
body {
max-width: 1200px;
margin: 5em auto 0 auto;
}
code,
pre {
display: inline;
font-family: "Roboto Mono", monospace;
font-size: 16px;
}
input {
font-family: "Roboto Mono", monospace;
color: #fff;
border-style: none;
height: 21px;
font-size: 16px;
}
button {
font-size: 16px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: 400;
}
h2 {
margin: 0 0 45px 0;
color: #fff;
font-size: 36px;
}
h3 {
margin: 0 0 2rem 0;
color: #fff;
font-size: 32px;
}
p {
margin: 0 0 45px;
line-height: 1.8;
}
ul {
margin: 0 0 32px;
}
a {
transition: border 0.2s ease-in-out;
border-bottom: 1px solid transparent;
text-decoration: none;
color: #00b7ef;
}
a:hover {
border-bottom-color: #00b7ef;
}
li {
line-height: 1.8;
}
li strong {
color: #fff;
}
.image {
width: 100%;
margin: 0 0 32px;
padding: 0;
}
.image img {
width: 100%;
}
.optanon-alert-box-wrapper {
left: 0;
}
.hidden {
display: none !important;
}
.hide-mobile {
display: none;
}
@media (min-width: 680px) {
body {
margin: 8em auto 0 auto;
}
.hide-mobile {
display: initial;
}
}
.header {
position: fixed;
top: 0;
left: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
padding: 3em 0 0;
background: #000;
}
.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;
}
.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: #9b9b9b;
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: #fff;
font-weight: 700;
}
.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: #fff;
}
.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: #9b9b9b;
}
.header .header-container .header-right .header-nav-item:hover a,
.header .header-container .header-right .header-nav-item:hover button {
color: #fdfff5;
}
@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,
background-color 0.3s ease-in-out;
color: #fff;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: transparent;
border: 1px solid #595b5c;
}
.button-dark:hover {
border-color: #fff;
}
.button-colorful {
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: #fff;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #d43aff;
border: 1px solid #d43aff;
}
.button-colorful:hover {
background-color: #c907ff;
border-color: #c907ff;
}
.button-orange {
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: #fff;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #eb5424;
border: 1px solid #eb5424;
}
.button-orange:hover {
background-color: #ca3f12;
border-color: #ca3f12;
}
.button-colorful:disabled {
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: #fff;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #9a9a9a;
border: 1px solid #9a9a9a;
}
.hero-container {
max-width: 795px;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto 70px auto;
}
.hero-container .hero-logo {
margin-top: 88px;
margin-bottom: 32px;
}
.hero-container .hero-subtitle {
font-size: 20px;
text-align: center;
line-height: 32px;
margin: 0 0 45px 0;
}
.hero-container .hero-buttons-container {
display: flex;
}
.hero-container .hero-buttons-container button {
margin: 0 8px;
}
@media (min-width: 680px) {
.hero-container {
margin: 0 auto 140px auto;
}
}
.container {
transition: opacity 0.2s ease-in-out;
color: #fff;
letter-spacing: 0;
opacity: 1;
}
.container .content {
width: 100%;
flex-grow: 1;
}
.container .content .step-title {
color: #fff;
font-size: 20px;
font-weight: 500;
line-height: 86px;
opacity: 1;
}
.container .content .step-subtitle {
position: relative;
top: -5px;
font-size: 16px;
font-weight: 300;
}
@media (max-width: 680px) {
.container .content {
margin-top: 120px;
}
}
@media (min-width: 680px) {
.container {
position: relative;
left: 100px;
display: flex;
width: calc(100% - 100px);
padding: 0;
}
.container .content {
max-width: 795px;
padding-left: 116px;
}
.container .content .step-title {
font-size: 36px;
}
}
.input-box {
display: flex;
align-items: center;
margin: 10px 0;
padding: 0 20px;
width: 100%;
height: 73px;
box-sizing: border-box;
background-color: #2f2f2f;
border-radius: 3px;
font-family: "Roboto Mono", monospace;
font-size: 13px;
}
.input-box .label {
text-transform: lowercase;
margin: 0 1ch 0 0;
}
.input-box .input {
background-color: #494848;
border-radius: 6px;
outline: 0;
border: 3px solid #494848;
width: 100%;
max-width: 444px;
font-size: 13px;
}
@media (min-width: 680px) {
.input-box {
padding: 0 30px;
margin: 32px 0;
}
}
.btn-container {
display: flex;
justify-content: space-between;
align-items: first baseline;
}
@media (max-width: 680px) {
.btn-container {
align-items: flex-start;
}
.btn-container p {
margin-left: 1rem;
}
}
.warning {
margin: 20px 0;
padding: 1rem;
width: 100%;
box-sizing: border-box;
background-color: rgb(255 228 230);
border: 1px solid rgb(225 29 72);
border-radius: 6px;
color: rgb(225 29 72);
font-size: 16px;
}
.warning a {
color: rgb(225 29 72);
text-decoration: underline;
}
.warning.hidden {
display: none;
}
.error {
margin-top: 10rem;
padding: 30px 40px;
background: #2f3132;
color: #fff;
}
.error p {
margin: 0 0 1rem;
}
.error p.intro {
font-size: 1.3rem;
}
.error .button-colorful {
display: inline-block;
}
@media (min-width: 680px) {
.error {
padding: 65px 80px;
}
}
.footer-branding-container {
color: #fff;
font-weight: 300;
margin-bottom: 73px;
}
.footer-branding-container .footer-branding {
display: flex;
width: 400px;
}
.footer-branding-container .footer-branding {
flex-direction: column;
text-align: center;
margin: 30px 0 0;
}
.footer-branding-container .footer-branding .footer-crafted-by-container {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: #fff;
}
.footer-branding-container .footer-branding .footer-crafted-by-container span {
display: inline-block;
margin: 3px 1ch 0 0;
}
.footer-branding-container
.footer-branding
.footer-crafted-by-container
.footer-branded-crafted-img {
height: 28px;
}
.footer-branding-container .footer-branding .footer-copyright {
color: #696969;
letter-spacing: 0.5px;
}
.footer-container {
width: 100%;
color: #fff;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
}
@media (min-width: 680px) {
.footer-container {
padding: 0;
}
}
.simple-loader {
--b: 20px;
--n: 15;
--g: 7deg;
--c: #d43aff;
width: 40px;
aspect-ratio: 1;
border-radius: 50%;
padding: 1px;
background: conic-gradient(#0000, var(--c)) content-box;
--_m: repeating-conic-gradient(
#0000 0deg,
#000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
#0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
),
radial-gradient(
farthest-side,
#0000 calc(98% - var(--b)),
#000 calc(100% - var(--b))
);
-webkit-mask: var(--_m);
mask: var(--_m);
-webkit-mask-composite: destination-in;
mask-composite: intersect;
animation: load 1s infinite steps(var(--n));
}
@keyframes load {
to {
transform: rotate(1turn);
}
}

View File

@@ -0,0 +1,3 @@
export const BASE_API_URI = import.meta.env.DEV
? import.meta.env.VITE_BASE_API_URI_DEV
: import.meta.env.VITE_BASE_API_URI_PROD;

View File

@@ -0,0 +1,105 @@
// @ts-nocheck
import { quintOut } from "svelte/easing";
import { crossfade } from "svelte/transition";
export const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
// eslint-disable-next-line no-unused-vars
fallback(node, params) {
const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform;
return {
duration: 600,
easing: quintOut,
css: (t) => `
transform: ${transform} scale(${t});
opacity: ${t}
`,
};
},
});
/**
* Validates an email field
* @file lib/utils/helpers/input.validation.ts
* @param {string} email - The email to validate
*/
export const isValidEmail = (email) => {
const EMAIL_REGEX =
/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
return EMAIL_REGEX.test(email.trim());
};
/**
* Validates a strong password field
* @file lib/utils/helpers/input.validation.ts
* @param {string} password - The password to validate
*/
export const isValidPasswordStrong = (password) => {
const strongRegex = new RegExp(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})"
);
return strongRegex.test(password.trim());
};
/**
* Validates a medium password field
* @file lib/utils/helpers/input.validation.ts
* @param {string} password - The password to validate
*/
export const isValidPasswordMedium = (password) => {
const mediumRegex = new RegExp(
"^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})"
);
return mediumRegex.test(password.trim());
};
/**
* Test whether or not an object is empty.
* @param {Record<string, string>} obj - The object to test
* @returns `true` or `false`
*/
export function isEmpty(obj) {
for (const _i in obj) {
return false;
}
return true;
}
/**
* Test whether or not an object is empty.
* @param {any} obj - The object to test
* @returns `true` or `false`
*/
export function formatError(obj) {
const errors = [];
if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
obj.forEach((/** @type {Object} */ error) => {
Object.keys(error).map((k) => {
errors.push({
error: error[k],
id: Math.random() * 1000,
});
});
});
} else {
Object.keys(obj).map((k) => {
errors.push({
error: obj[k],
id: Math.random() * 1000,
});
});
}
} else {
errors.push({
error: obj.charAt(0).toUpperCase() + obj.slice(1),
id: 0,
});
}
return errors;
}

View File

@@ -0,0 +1,105 @@
// @ts-nocheck
import { quintOut } from "svelte/easing";
import { crossfade } from "svelte/transition";
export const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
// eslint-disable-next-line no-unused-vars
fallback(node, params) {
const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform;
return {
duration: 600,
easing: quintOut,
css: (t) => `
transform: ${transform} scale(${t});
opacity: ${t}
`,
};
},
});
/**
* Validates an email field
* @file lib/utils/helpers/input.validation.ts
* @param {string} email - The email to validate
*/
export const isValidEmail = (email) => {
const EMAIL_REGEX =
/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
return EMAIL_REGEX.test(email.trim());
};
/**
* Validates a strong password field
* @file lib/utils/helpers/input.validation.ts
* @param {string} password - The password to validate
*/
export const isValidPasswordStrong = (password) => {
const strongRegex = new RegExp(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})"
);
return strongRegex.test(password.trim());
};
/**
* Validates a medium password field
* @file lib/utils/helpers/input.validation.ts
* @param {string} password - The password to validate
*/
export const isValidPasswordMedium = (password) => {
const mediumRegex = new RegExp(
"^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})"
);
return mediumRegex.test(password.trim());
};
/**
* Test whether or not an object is empty.
* @param {Record<string, string>} obj - The object to test
* @returns `true` or `false`
*/
export function isEmpty(obj) {
for (const _i in obj) {
return false;
}
return true;
}
/**
* Test whether or not an object is empty.
* @param {any} obj - The object to test
* @returns `true` or `false`
*/
export function formatError(obj) {
const errors = [];
if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
obj.forEach((/** @type {Object} */ error) => {
Object.keys(error).map((k) => {
errors.push({
error: error[k],
id: Math.random() * 1000,
});
});
});
} else {
Object.keys(obj).map((k) => {
errors.push({
error: obj[k],
id: Math.random() * 1000,
});
});
}
} else {
errors.push({
error: obj.charAt(0).toUpperCase() + obj.slice(1),
id: 0,
});
}
return errors;
}

View File

@@ -0,0 +1,5 @@
/** @type {import('./$types').LayoutLoad} */
export async function load({ fetch, url, data }) {
const { user } = data;
return { fetch, url: url.pathname, user };
}

View File

@@ -0,0 +1,6 @@
/** @type {import('./$types').LayoutServerLoad} */
export async function load({ locals }) {
return {
user: locals.user,
};
}

View File

@@ -0,0 +1,17 @@
<script>
import Footer from "$lib/components/Footer.svelte";
import Header from "$lib/components/Header.svelte";
import Transition from "$lib/components/Transition.svelte";
import "$lib/css/styles.min.css";
/** @type {import('./$types').PageData} */
export let data;
</script>
<Transition key={data.url} duration={600}>
<Header />
<slot />
<Footer />
</Transition>

View File

@@ -0,0 +1,19 @@
<!-- <script>
import Developer from "$lib/img/hero-image.png";
</script> -->
<div class="hero-container">
<!-- <div class="hero-logo"><img src={Developer} alt="Alexander Stölting" /></div> -->
<h3 class="hero-subtitle subtitle">
This application is the demonstration of a series of tutorials on
session-based authentication using Go at the backend and JavaScript
(SvelteKit) on the front-end.
</h3>
<div class="hero-buttons-container">
<a
class="button-dark"
href="https://dev.to/sirneij/series/23239"
data-learn-more>Learn more</a
>
</div>
</div>

View File

@@ -0,0 +1,98 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { formatError } from "$lib/utils/helpers";
import { fail, redirect } from "@sveltejs/kit";
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) {
// redirect user if logged in
if (locals.user) {
throw redirect(302, "/");
}
}
/** @type {import('./$types').Actions} */
export const actions = {
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @returns Error data or redirects user to the home page or the previous page
*/
login: async ({ request, fetch, cookies }) => {
const data = await request.formData();
const email = String(data.get("email"));
const password = String(data.get("password"));
const next = String(data.get("next"));
/** @type {RequestInit} */
const requestInitOptions = {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email,
password: password,
}),
};
const res = await fetch(`${BASE_API_URI}/users/login/`, requestInitOptions);
console.log("Login response status:", res.status);
console.log("Login response headers:", Object.fromEntries(res.headers));
if (!res.ok) {
let errorMessage = `HTTP error! status: ${res.status}`;
try {
const errorData = await res.json();
errorMessage = errorData.error || errorMessage;
} catch (parseError) {
console.error("Failed to parse error response:", parseError);
errorMessage = await res.text(); // Get the raw response text if JSON parsing fails
}
console.error("Login failed:", errorMessage);
return fail(res.status, {
errors: [{ error: errorMessage, id: Date.now() }],
});
}
const responseBody = await res.json();
console.log("Login response body:", responseBody);
// Check for the cookie in the response headers
const setCookieHeader = res.headers.get("set-cookie");
console.log("Set-Cookie header:", setCookieHeader);
if (setCookieHeader) {
// Parse the Set-Cookie header to get the JWT
const jwtCookie = setCookieHeader.split(";")[0];
const [cookieName, cookieValue] = jwtCookie.split("=");
if (cookieName.trim() === "jwt") {
console.log("JWT cookie found in response");
cookies.set("jwt", cookieValue.trim(), {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
});
} else {
console.log("JWT cookie not found in response");
}
} else {
console.log("No Set-Cookie header in response");
}
console.log("Redirecting to:", next || "/");
throw redirect(303, next || "/");
},
// if (!res.ok) {
// const response = await res.json();
// const errors = formatError(response.error);
// return fail(400, { errors: errors });
// }
// throw redirect(303, next || "/");
// },
};

View File

@@ -0,0 +1,76 @@
<script>
import { applyAction, enhance } from "$app/forms";
import { page } from "$app/stores";
import { receive, send } from "$lib/utils/helpers";
/** @type {import('./$types').ActionData} */
export let form;
/** @type {import('./$types').SubmitFunction} */
const handleLogin = async () => {
return async ({ result }) => {
await applyAction(result);
};
};
let message = "";
if ($page.url.searchParams.get("message")) {
message = $page.url.search.split("=")[1].replaceAll("%20", " ");
}
</script>
<div class="container">
<form
class="content"
method="POST"
action="?/login"
use:enhance={handleLogin}
>
<h1 class="step-title">Login User</h1>
{#if form?.errors}
{#each form?.errors as error (error.id)}
<h4
class="step-subtitle warning"
in:receive={{ key: error.id }}
out:send={{ key: error.id }}
>
{error.error}
</h4>
{/each}
{/if}
{#if message}
<h4 class="step-subtitle">{message}</h4>
{/if}
<input
type="hidden"
name="next"
value={$page.url.searchParams.get("next")}
/>
<div class="input-box">
<span class="label">Email:</span>
<input
class="input"
type="email"
name="email"
placeholder="Email address"
/>
</div>
<div class="input-box">
<span class="label">Password:</span>
<input
class="input"
type="password"
name="password"
placeholder="Password"
/>
<a href="/auth/password/request-change" style="margin-left: 1rem;"
>Forgot password?</a
>
</div>
<div class="btn-container">
<button class="button-dark">Login</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,43 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { fail, redirect } from "@sveltejs/kit";
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) {
// redirect user if not logged in
if (!locals.user) {
throw redirect(302, `/auth/login?next=/auth/logout`);
}
}
/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ fetch, cookies }) => {
/** @type {RequestInit} */
const requestInitOptions = {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
Cookie: `jwt=${cookies.get("jwt")}`,
},
};
const res = await fetch(
`${BASE_API_URI}/users/backend/logout/`,
requestInitOptions
);
if (!res.ok) {
const response = await res.json();
const errors = [];
errors.push({ error: response.error, id: 0 });
return fail(400, { errors: errors });
}
// eat the cookie
cookies.delete("jwt", { path: "/" });
// redirect the user
throw redirect(302, "/auth/login");
},
};

16
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,16 @@
// import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-vercel';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({
runtime: 'edge'
})
}
};
export default config;

9
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});