dev
All checks were successful
ci / docker_image (push) Successful in 1m32s
ci / deploy (push) Successful in 16s

This commit is contained in:
Drake Marino 2025-01-26 19:12:15 -06:00
parent 76c2680c60
commit be83b7570d
43 changed files with 3202 additions and 1022 deletions

3
.gitignore vendored
View File

@ -26,3 +26,6 @@ vite.config.ts.timestamp-*
# Postgres
postgresql
# User uploads
uploads

View File

@ -1,38 +1,7 @@
# sv
## FBLA25
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
## Future Features
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
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
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
- [ ] Separation of companies and employers

1492
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,10 +16,11 @@
"devDependencies": {
"@eslint/compat": "^1.2.3",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/kit": "^2.16.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.7",
"@types/node-fetch": "^2.6.12",
"eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",

View File

@ -1,11 +1,14 @@
## Table of Permissions
| Permission Value | Description |
|------------------|------------------------|
| `00000001` | View postings |
| `00000010` | View account |
| `00000100` | Apply for jobs |
| `00001000` | Submit postings |
| `00010000` | Manage tags |
| `00100000` | Manage postings |
| `01000000` | Manage users |
| Permission Value | Description |
|---------------------|------------------|
| `0b00000001`(bit 0) | View access |
| `0b00000010`(bit 1) | Apply for jobs |
| `0b00000100`(bit 2) | Submit postings |
| `0b00001000`(bit 3) | Manage employers |
| `0b00010000`(bit 4) | Manage tags |
| `0b00100000`(bit 5) | Manage postings |
| `0b01000000`(bit 6) | Manage users |
| `0b10000000`(bit 7) | Manage companies |
**Note**: Users that are not logged in are assigned a permission value of `0b00000001` (View access only).

View File

@ -13,18 +13,20 @@
--dull-primary-color: #51aaf0;
--elevated-bg-color: #ffffff;
--bg-accent-color: #f4f4f4;
--danger-color: #ff2d2f;
}
[data-theme='dark'] {
--text-color: #f4f4f4;
--bg-color: #080808;
--hover-bg-color: #1f2937;
--bg-color: #0c0c0c;
--hover-bg-color: #1a1c1c;
--separator-line-color: #1a2029;
--low-emphasis-text-color: #999999;
--primary-color: #1F96F3;
--dull-primary-color: #1569ab;
--elevated-bg-color: #0c0c0d;
--elevated-bg-color: #101011;
--bg-accent-color: #202020;
--danger-color: #ff1d1f;
}
body {
@ -37,9 +39,8 @@ h1 {
@apply text-4xl
}
.elevated {
background-color: var(--separator-line-color);
@apply shadow-lg
.bg-color {
background-color: var(--bg-color);
}
.hover-bg-color:hover {
@ -50,7 +51,11 @@ h1 {
color: var(--low-emphasis-text-color);
}
.low-emphasis-text:hover {
.low-emphasis-text-button {
color: var(--low-emphasis-text-color);
}
.low-emphasis-text-button:hover {
color: var(--text-color);
}
@ -84,18 +89,23 @@ h1 {
'opsz' 20
}
input[type='search'], input[type='text'], input[type='password'] {
input[type='search'], input[type='text'], input[type='password'], input[type='email'], input[type='tel'] {
background-color: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--separator-line-color);
}
input[type='search']:focus, input[type='text']:focus, input[type='password']:focus{
input[type='search']:focus, input[type='text']:focus, input[type='password']:focus, input[type='email']:focus, input[type='tel']:focus {
outline: 0 solid var(--text-color);
border: 1px solid var(--primary-color);
box-shadow: 0 0 0 0 var(--primary-color);
}
input[type='search']:-webkit-autofill, input[type='text']:-webkit-autofill, input[type='password']:-webkit-autofill, input[type='email']:-webkit-autofill, input[type='tel']:-webkit-autofill {
-webkit-text-fill-color: var(--text-color) !important;
background-clip: text !important;
}
.input-field {
border: 1px solid var(--separator-line-color);
border-radius: 4px;
@ -181,7 +191,7 @@ th.left, td.left {
padding-left: 0.5rem;
}
.borders {
.separator-borders {
border: 1px solid var(--separator-line-color);
}
@ -198,3 +208,142 @@ th.left, td.left {
background-color: var(--dull-primary-color);
}
.dull-primary-text-color {
color: var(--dull-primary-color);
}
.primary-text-color {
color: var(--primary-color);
}
.accordion {
cursor: pointer;
width: 100%;
transition: 0.2s;
}
.accordion:hover, .active {
background-color: var(--hover-bg-color);
}
input[type='checkbox'] {
background-color: var(--bg-color);
border: 1px solid var(--separator-line-color);
border-radius: 4px;
}
input[type='checkbox']:focus {
box-shadow: none;
outline: none;
}
.danger-bg-color {
background-color: var(--danger-color);
}
.danger-border-color {
border-color: var(--danger-color);
}
/* The Modal (background) */
.modal {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
z-index: 1; /* Sit on top */
left: 0;
top: 0;
width: 100%; /* Full width */
height: 100%; /* Full height */
overflow: auto; /* Enable scroll if needed */
background-color: rgb(0,0,0); /* Fallback color */
background-color: rgba(0,0,0,0.6); /* Black w/ opacity */
}
/* Modal Content/Box */
.modal-content {
background-color: var(--bg-color);
margin: 10% auto; /* 10% from the top and centered */
padding: 16px;
border: 1px solid var(--separator-line-color);
max-width: 420px;
border-radius: 4px;
}
/* The Close Button */
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
h2 {
color: var(--text-color);
@apply text-2xl
}
.tooltip {
position: relative;
display: inline-block;
}
/* Tooltip text */
.tooltip .tooltip-text {
visibility: hidden;
background-color: var(--bg-accent-color);
color: var(--text-color);
text-align: center;
padding: 4px;
border-radius: 6px;
/* Position the tooltip text - see examples below! */
position: absolute;
z-index: 1;
width: 100px;
bottom: 120%;
left: 50%;
margin-left: -50px; /* Use half of the width (120/2 = 60), to center the tooltip */
opacity: 0;
transition: opacity 0s linear 0.5s;
}
.tooltip .tooltip-text::after {
content: " ";
position: absolute;
top: 100%; /* At the bottom of the tooltip */
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: var(--bg-accent-color) transparent transparent transparent;
}
/* Show the tooltip text when you mouse over the tooltip container */
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
.tooltip .tooltip-text:hover {
visibility: hidden;
opacity: 0;
}
.hover-hyperlink:hover {
color: var(--dull-primary-color);
text-decoration: underline var(--dull-primary-color);;
}
.max-char-length {
max-width: 200ch;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -1,9 +1,10 @@
export const PERMISSIONS = {
VIEW_POSTINGS: 0b00000001,
VIEW_ACCOUNT: 0b00000010,
APPLY_FOR_JOBS: 0b00000100,
SUBMIT_POSTINGS: 0b00001000,
VIEW: 0b00000001,
APPLY_FOR_JOBS: 0b0000010,
SUBMIT_POSTINGS: 0b0000100,
MANAGE_EMPLOYERS: 0b00001000,
MANAGE_TAGS: 0b00010000,
MANAGE_POSTINGS: 0b00100000,
MANAGE_USERS: 0b01000000
MANAGE_USERS: 0b01000000,
MANAGE_COMPANIES: 0b10000000
};

View File

@ -1,100 +1,192 @@
import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server';
import type { Cookies } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { error } from '@sveltejs/kit';
import { saveAvatar } from '$lib/index.server';
export async function createUser(username: string, password: string): Promise<void> {
const password_hash: string = await bcrypt.hash(password, 12);
const timestamp = new Date(Date.now()).toISOString();
console.log(timestamp);
export async function createUser(user: User): Promise<number> {
const password_hash: string = await bcrypt.hash(user.password!, 12);
const response = await sql`
INSERT INTO users (username, password_hash, perms, created_at, last_signin, active)
VALUES (${username}, ${password_hash}, 3, ${timestamp}, ${timestamp}, ${true});
INSERT INTO users (username, password_hash, perms, created_at, last_signin, active, email, phone, full_name, company_code)
VALUES (${user.username}, ${password_hash}, ${user.perms}, NOW(), NOW(), ${user.active}, ${user.email}, ${user.phone}, ${user.fullName}, ${user.companyCode})
RETURNING id;
`;
// TODO: handle custom image uploads
user.id = response[0].id;
await saveAvatar(user);
return response[0].id;
}
export async function checkUserCreds(username: string, password: string): Promise<number> {
export async function updateUser(user: User): Promise<number> {
let password_hash: string | null = null;
// Hash the password if provided
if (user.password) {
password_hash = await bcrypt.hash(user.password, 12);
}
// Construct the SQL query
const response = await sql`UPDATE users
SET
username = ${user.username},
perms = ${user.perms},
active = ${user.active},
${password_hash !== null ? sql`password_hash = ${password_hash},` : sql``}
email = ${user.email},
phone = ${user.phone},
full_name = ${user.fullName},
company_code = ${user.companyCode}
WHERE id = ${user.id}
RETURNING id;`;
await saveAvatar(user);
return response[0].id;
}
export async function checkUserCreds(username: string, password: string): Promise<User | null> {
const [user] = await sql`
SELECT password_hash, perms
SELECT id, password_hash, perms, active
FROM users
WHERE username = ${username}
`;
if (!user) {
return -1;
return null;
}
if (await bcrypt.compare(password, user.password_hash)) {
return user['perms'];
return <User>{ id: user.id, perms: user.perms, active: user.active };
}
return -1;
}
export function getUserPerms(cookies: Cookies): number {
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const JWT = cookies.get('jwt');
if (JWT) {
try {
const decoded = jwt.verify(JWT, process.env.JWT_SECRET);
if (typeof decoded === 'object' && 'perms' in decoded) {
return decoded['perms'];
}
} catch (err) {
return -1;
}
}
return -1;
return null;
}
// should require MANAGE_USERS permission
export async function getUsers(): Promise<User[]> {
const users = await sql<
{
id: number;
username: string;
perms: number;
created_at: Date;
last_signin: Date;
active: boolean;
}[]
>`
SELECT id, username, perms,
created_at AT TIME ZONE 'UTC' AS created_at,
last_signin AT TIME ZONE 'UTC' AS last_signin,
active
FROM users;
export async function getUsers(searchQuery: string | null = null): Promise<User[]> {
const users = await sql`
SELECT id,
username,
perms,
created_at AT TIME ZONE 'UTC' AS "createdAt",
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
active,
email,
phone,
full_name AS "fullName",
company_id
FROM users
WHERE username ILIKE ${searchQuery ? `%${searchQuery}%` : '%'};
`;
users.forEach((user) => {
user.company = {
id: user.company_id
};
delete user.company_id;
});
return <User[]>(<unknown>users);
}
// should require MANAGE_USERS permission
export async function getUser(id: number): Promise<User> {
const [user] = await sql`
SELECT id, username, perms,
created_at AT TIME ZONE 'UTC' AS "createdAt",
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
active, email, phone, full_name AS "fullName", company_id, company_code
FROM users
WHERE id = ${id};
`;
if (!user) {
error(404, 'User not found');
}
user.company = {
id: user.company_id
};
delete user.company_id;
return <User>user;
}
export async function getUserWithCompany(id: number): Promise<User> {
const [user] = await sql`
SELECT
u.id,
u.username,
u.perms,
u.email,
u.phone,
u.full_name AS "fullName",
u.created_at AT TIME ZONE 'UTC' AS "createdAt",
u.last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
u.active,
c.id AS company_id,
c.name AS company_name,
c.description AS company_description,
c.website AS company_website,
c.created_at AS company_created_at
FROM
users u
LEFT JOIN
companies c ON u.company_id = c.id
WHERE
u.id = ${id};
`;
if (!user) {
error(404, 'User not found');
}
user.company = {
id: user.company_id,
name: user.company_name,
description: user.company_description,
website: user.company_website,
createdAt: user.company_created_at
};
// Remove the company-specific columns from the user object
delete user.company_id;
delete user.company_name;
delete user.company_description;
delete user.company_website;
delete user.company_created_at;
return <User>user;
}
// should require MANAGE_USERS permission
export async function deleteUser(id: number): Promise<void> {
const response = await sql`
DELETE FROM users
WHERE id = ${id};
`;
return users.map(
(user): User => ({
id: user.id,
username: user.username,
perms: user.perms,
created_at: user.created_at,
last_signin: user.last_signin,
active: user.active
})
);
}
// should require MANAGE_TAGS permission
export async function getTags(): Promise<Tag[]> {
const tags = await sql<
{
id: number;
display_name: string;
}[]
>`
SELECT id, display_name
FROM tags;
export async function getTags(searchQuery: string | null): Promise<Tag[]> {
return sql<Tag[]>`
SELECT id, display_name as "displayName", created_at AT TIME ZONE 'UTC' AS "createdAt"
FROM tags
WHERE display_name ILIKE ${searchQuery ? `%${searchQuery}%` : '%'};
`;
return tags.map(
(tag): Tag => ({
id: tag.id,
display_name: tag.display_name
})
);
}
export async function updateLastSignin(username: string): Promise<void> {
await sql`
UPDATE users
SET last_signin = NOW()
WHERE username = ${username};
`;
}
export async function createCompany(
name: string,
description: string,
website: string
): Promise<number> {
const response = await sql`
INSERT INTO companies (name, description, website, created_at, company_code)
VALUES (${name}, ${description}, ${website}, NOW(), generate_company_code(CAST(CURRVAL('companies_id_seq') AS INT)))
RETURNING id;
`;
return response[0].id;
}

53
src/lib/index.server.ts Normal file
View File

@ -0,0 +1,53 @@
import fs from 'fs';
import path from 'path';
import fetch from 'node-fetch';
import { type Cookies, error } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { getUserWithCompany } from '$lib/db/index.server';
// TODO: Handle saving custom avatar uploads
export async function saveAvatar(user: User): Promise<void> {
const url = `https://ui-avatars.com/api/?background=random&format=svg&name=${user.fullName ? encodeURIComponent(user.fullName) : encodeURIComponent(user.username)}`;
const response = await fetch(url, { headers: { accept: 'image/svg+xml' } });
const avatar = await response.text();
const filePath = path.join('static', 'uploads', 'avatars', `${user.id}.svg`);
fs.writeFileSync(filePath, avatar);
}
export function getUserPerms(cookies: Cookies): number {
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const JWT = cookies.get('jwt');
if (JWT) {
try {
const decoded = jwt.verify(JWT, process.env.JWT_SECRET);
if (typeof decoded === 'object' && 'perms' in decoded) {
return decoded['perms'];
}
} catch (err) {
return -1;
}
}
return -1;
}
export function getUserId(cookies: Cookies): number {
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const JWT = cookies.get('jwt');
if (JWT) {
try {
const decoded = jwt.verify(JWT, process.env.JWT_SECRET);
if (typeof decoded === 'object' && 'id' in decoded) {
return decoded['id'];
}
} catch (err) {
error(403, 'Unauthorized');
}
}
error(403, 'Unauthorized');
}

View File

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -1 +1,21 @@
export let userState = $state({ perms: 0b00000001 });
import { PERMISSIONS } from '$lib/consts';
export let userState = $state({ perms: PERMISSIONS.VIEW, username: null, id: null });
export const userPerms = PERMISSIONS.VIEW | PERMISSIONS.APPLY_FOR_JOBS;
export const employerPerms = PERMISSIONS.SUBMIT_POSTINGS | PERMISSIONS.MANAGE_EMPLOYERS;
export const adminPerms =
PERMISSIONS.MANAGE_TAGS |
PERMISSIONS.MANAGE_POSTINGS |
PERMISSIONS.MANAGE_USERS |
PERMISSIONS.MANAGE_COMPANIES;
export function telFormatter(initial: string) {
const num = initial.replace(/\D/g, '');
initial =
(num.length > 0 ? '(' : '') +
num.substring(0, 3) +
(num.length > 3 ? ') ' + num.substring(3, 6) : '') +
(num.length > 6 ? '-' + num.substring(6, 10) : '');
return initial;
}

View File

@ -1,13 +1,28 @@
interface User {
id: number | null;
username: string;
password: string | null;
perms: number;
created_at: Date | null;
last_signin: Date | null;
createdAt: Date | null;
lastSignIn: Date | null;
active: boolean | null;
email: string | null;
phone: string | null;
fullName: string | null;
company: Company | null;
companyCode: string | null;
}
interface Tag {
id: number;
display_name: string;
displayName: string;
createdAt: Date | null;
}
interface Company {
id: number;
name: string;
description: string;
website: string;
createdAt: Date | null;
}

View File

@ -3,6 +3,7 @@
import { onMount } from 'svelte';
import { userState } from '$lib/shared.svelte';
import { PERMISSIONS } from '$lib/consts';
import { updateUserState } from './utils.client';
let currentTheme: string = $state('');
@ -12,15 +13,12 @@
}
function setTheme(theme: string) {
const one_year = 60 * 60 * 24 * 365;
document.cookie = `theme=${theme}; max-age=${one_year}; path=/`;
const oneYear = 60 * 60 * 24 * 365;
document.cookie = `theme=${theme}; max-age=${oneYear}; path=/`;
document.documentElement.setAttribute('data-theme', theme);
currentTheme = theme;
}
const getCookieValue = (name: String) =>
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
onMount(() => {
const savedTheme = document.documentElement.getAttribute('data-theme');
if (savedTheme) {
@ -31,18 +29,25 @@
setTheme(currentTheme);
}
const JWT = getCookieValue('jwt');
if (JWT !== '') {
userState.perms = JSON.parse(atob(JWT.split('.')[1])).perms;
}
updateUserState();
});
if ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0) {
('/admin/postings');
} else if ((userState.perms & PERMISSIONS.MANAGE_USERS) !== 0) {
('/admin/employers');
} else if ((userState.perms & PERMISSIONS.MANAGE_TAGS) !== 0) {
('/admin/tags');
} else if ((userState.perms & PERMISSIONS.MANAGE_COMPANIES) !== 0) {
('/admin/companies');
}
let { children } = $props();
</script>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..40,400,0,0&display=block&icon_names=account_circle,arrow_drop_down,check,close,dark_mode,edit,group,info,light_mode,login,person,search,sell,visibility,visibility_off,work"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..40,400,0,0&display=block&icon_names=account_circle,arrow_drop_down,arrow_drop_up,calendar_today,call,check,close,dark_mode,edit,group,info,light_mode,login,mail,person,search,sell,store,visibility,visibility_off,work"
/>
<div class="bottom-border flex h-14 justify-between p-3 align-middle">
@ -57,12 +62,24 @@
/>
</a>
<a href="/about" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">About</a>
{#if (userState.perms & PERMISSIONS.VIEW_POSTINGS) !== 0}
{#if (userState.perms & PERMISSIONS.VIEW) !== 0}
<a href="/listings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Listings</a>
{/if}
{#if (userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0}
<a href="/admin/postings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm"
>Administration</a
{#if (userState.perms & PERMISSIONS.VIEW) !== 0}
<a href="/companies" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Companies</a>
{/if}
{#if (userState.perms & (PERMISSIONS.MANAGE_POSTINGS | PERMISSIONS.MANAGE_TAGS | PERMISSIONS.MANAGE_USERS)) !== 0}
<a
href={(userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0
? '/admin/postings'
: (userState.perms & PERMISSIONS.MANAGE_USERS) !== 0
? '/admin/users'
: (userState.perms & PERMISSIONS.MANAGE_TAGS) !== 0
? '/admin/tags'
: (userState.perms & PERMISSIONS.MANAGE_COMPANIES) !== 0
? '/admin/companies'
: '/admin'}
class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Administration</a
>
{/if}
</nav>
@ -72,13 +89,9 @@
{currentTheme === 'light' ? 'light_mode' : 'dark_mode'}
</span>
</button>
<button
onclick={() =>
(window.location.href =
(userState.perms & PERMISSIONS.VIEW_ACCOUNT) !== 0 ? '/account' : '/signin')}
>
<button onclick={() => (window.location.href = userState.id !== null ? '/account' : '/signin')}>
<span class="material-symbols-outlined hover-bg-color rounded-full p-1">
{(userState.perms & PERMISSIONS.VIEW_ACCOUNT) !== 0 ? 'account_circle' : 'login'}
{userState.id !== null ? 'account_circle' : 'login'}
</span>
</button>
</div>

View File

@ -1,2 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { updateUserState } from './utils.client';
onMount(() => {
updateUserState();
});
</script>

View File

@ -0,0 +1,10 @@
import type { PageServerLoad } from './$types';
import { getUserWithCompany } from '$lib/db/index.server';
import { error } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { getUserId } from '$lib/index.server';
export const load: PageServerLoad = async ({ cookies }) => {
const id = getUserId(cookies);
return { user: await getUserWithCompany(id) };
};

View File

@ -1,9 +1,132 @@
<script>
<script lang="ts">
import { onMount } from 'svelte';
import type { PageProps } from './$types';
onMount(() => {
if (!document.cookie.includes('jwt=')) {
window.location.href = '/signin';
}
});
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
function avatarFallback(e: Event) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${data.user.fullName ? encodeURIComponent(data.user.fullName) : encodeURIComponent(data.user.username)}`;
}
function logoFallback(e: Event) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(data.user.company!.name!)}`;
}
function signOut() {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
window.location.href = '/signin';
}
let { data, form }: PageProps = $props();
</script>
<div class="base-container">
<div class="content flex py-4">
<div class="elevated separator-borders m-2 inline-block h-min min-w-max rounded align-top">
<div class="inline-block p-4">
<img
id="avatar"
class="mb-2 inline-block rounded-lg"
src="/uploads/avatars/{data.user.id}.svg?timestamp=${Date.now()}"
onerror={avatarFallback}
alt="User avatar"
height="240"
width="240"
/>
{#if data.user.fullName}
<h2 class="text-center font-semibold">{data.user.fullName}</h2>
<h3 class="text-center">{data.user.username}</h3>
{/if}
{#if !data.user.fullName}
<h2 class="text-center font-semibold">{data.user.username}</h2>
{/if}
</div>
{#if data.user.email}
<div class="top-border p-3">
<span class="material-symbols-outlined align-middle">mail</span>
<a class="hover-hyperlink" href="mailto:{data.user.email}">{data.user.email}</a>
</div>
{/if}
{#if data.user.phone}
<div class="top-border p-3">
<span class="material-symbols-outlined align-middle">call</span>
<a class="hover-hyperlink" href="tel:{data.user.phone}">{data.user.phone}</a>
</div>
{/if}
{#if data.user.createdAt}
<div class="top-border p-3">
<span class="material-symbols-outlined align-middle">calendar_today</span>
Joined {data.user.createdAt.toLocaleDateString('en-US', dateFormatOptions)}
</div>
{/if}
</div>
<div class="elevated separator-borders m-2 inline-block h-min w-full rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">User Details</div>
<div class="flex">
<a class="dull-primary-bg-color my-2 rounded-md px-2.5 py-1" href="/account/settings"
>Edit account</a
>
<button class="danger-border-color m-2 rounded-md border px-2.5 py-1" onclick={signOut}
>Sign out</button
>
</div>
</div>
<div class="p-3">
<div class="font-semibold">
ID: <span class="font-normal">{data.user.id}</span>
</div>
<div class="font-semibold">
Account active: <span class="material-symbols-outlined align-middle"
>{data.user.active ? 'check' : 'cancel'}</span
>
</div>
<div class="font-semibold">
Last sign-in: <span class="font-normal"
>{data.user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions)}</span
>
</div>
{#if !data.user.company?.id}
<div class="pb-2 font-semibold">
Employer company: <span class="font-normal">N/A</span>
</div>
{/if}
{#if data.user.company?.id}
<div class="font-semibold">
Employer company:
<div class="top-border mt-2 p-3">
<img
id="logo"
class="mb-2 inline-block rounded"
src="/uploads/logos/{data.user.company.id}.svg?timestamp=${Date.now()}"
alt="Company Logo"
onerror={logoFallback}
height="32"
width="32"
/>
<div class="inline-block">
<div>{data.user.company.name}</div>
<div class="max-char-length font-normal">{data.user.company.description}</div>
</div>
</div>
</div>
{/if}
<div class="top-border pt-2 font-semibold">
Permissions: <span class="font-normal">{data.user.perms}</span>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,63 @@
import type { PageServerLoad } from './$types';
import { deleteUser, getUser, getUserWithCompany, updateUser } from '$lib/db/index.server';
import { type Actions, fail, redirect } from '@sveltejs/kit';
import { PERMISSIONS } from '$lib/consts';
import { getUserId } from '$lib/index.server';
export const load: PageServerLoad = async ({ cookies }) => {
const id = getUserId(cookies);
return { user: await getUserWithCompany(id) };
};
export const actions: Actions = {
submit: async ({ request, cookies }) => {
const id = getUserId(cookies);
const data = await request.formData();
const username = data.get('username')?.toString().trim();
let email: string | undefined | null = data.get('email')?.toString().trim();
let phone: string | undefined | null = data.get('phone')?.toString().trim();
let fullName: string | undefined | null = data.get('fullName')?.toString().trim();
let companyCode: string | undefined | null = data
.get('companyCode')
?.toString()
.toUpperCase()
.trim();
if (email === '' || email == undefined) email = null;
if (phone === '' || phone == undefined) phone = null;
if (fullName === '' || fullName == undefined) fullName = null;
if (companyCode === '' || companyCode == undefined) companyCode = null;
if (email && !email.includes('@')) {
return fail(400, { errorMessage: 'Invalid email' });
}
if (phone && !phone.match(/\((\d{3})\) \d{3}-\d{4}/)) {
return fail(400, { errorMessage: 'Invalid phone number' });
}
if (username && username !== '') {
try {
await updateUser(<User>{
id: id,
username: username,
email: email,
phone: phone,
fullName: fullName,
companyCode: companyCode
});
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
return redirect(301, `/account`);
}
},
delete: async ({ cookies }) => {
const id = getUserId(cookies);
try {
await deleteUser(id);
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
}
};

View File

@ -0,0 +1,198 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import { telFormatter } from '$lib/shared.svelte';
let permsAccordions: boolean[] = [false, false, false];
onMount(() => {
let acc = document.getElementsByClassName('accordion');
for (let i = 0; i < acc.length; i++) {
acc[i].addEventListener('click', function (this: HTMLElement, event: Event) {
const target = event?.target as HTMLElement | null;
if (target?.tagName === 'INPUT' && (target as HTMLInputElement).type === 'checkbox') {
return; // Do nothing if it's the checkbox
}
this.classList.toggle('active');
permsAccordions[i] = !permsAccordions[i];
let panel = this.nextElementSibling as HTMLElement;
if (panel.style.display === 'block') {
panel.style.display = 'none';
} else {
panel.style.display = 'block';
}
});
let selectAllCheckbox = acc[i].querySelector('.select-all') as HTMLInputElement;
selectAllCheckbox.addEventListener('change', function () {
let checkboxes =
this.parentElement!.parentElement!.parentElement!.nextElementSibling!.querySelectorAll(
'.permCheckbox'
) as NodeListOf<HTMLInputElement>;
checkboxes.forEach((checkbox) => {
checkbox.checked = selectAllCheckbox.checked;
});
});
let permCheckboxes = acc[i].nextElementSibling!.querySelectorAll(
'.permCheckbox'
) as NodeListOf<HTMLInputElement>;
permCheckboxes.forEach((checkbox) => {
checkbox.addEventListener('change', function () {
let allChecked = true;
let someChecked = false;
permCheckboxes.forEach((cb) => {
if (cb.checked) {
someChecked = true;
} else {
allChecked = false;
}
});
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = !allChecked && someChecked;
});
});
}
const modal: HTMLElement = document.getElementById('deleteConfirmModal') as HTMLElement;
window.onclick = function (event) {
if (event.target == modal) {
modal.style.display = 'none';
}
};
document.getElementById('phone')?.addEventListener('input', function (this: HTMLInputElement) {
this.value = telFormatter(this.value);
});
});
function openConfirm() {
document.getElementById('deleteConfirmModal')!.style.display = 'block';
}
function closeConfirm() {
document.getElementById('deleteConfirmModal')!.style.display = 'none';
}
let { data, form }: PageProps = $props();
let perms = data.user!.perms;
</script>
<div class="base-container">
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Edit Account</div>
</div>
<form method="POST" class="px-4" autocomplete="off" use:enhance>
<div class="mt-4 text-sm font-semibold">
Username
<input
type="text"
name="username"
id="username"
value={data.user?.username}
placeholder="Username"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Email (optional)
<input
type="email"
name="email"
id="email"
value={data.user?.email}
placeholder="Email"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Phone (optional)
<input
type="tel"
name="phone"
id="phone"
value={data.user?.phone}
placeholder="Phone"
class="w-full rounded font-normal"
pattern="([0-9]\{3}) [0-9]\{3}-[0-9]\{3}"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Full name (optional)
<input
type="text"
name="fullName"
id="fullName"
value={data.user?.fullName}
placeholder="Full name"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Company code (optional)
<input
type="text"
name="companyCode"
id="companyCode"
placeholder="Company code"
value={data.user?.companyCode}
class="w-full rounded font-normal"
/>
</div>
<p class="low-emphasis-text pb-4">
Enter a code here to join a company. You will still have to be approved before you can
make any postings.
</p>
{#if form?.errorMessage}
<div class="mb-2 text-red-500">{form.errorMessage}</div>
{/if}
<div class="flex justify-between">
<button
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
type="submit"
formaction="?/submit">Update account</button
>
<button
class="danger-bg-color mb-4 mt-2 rounded px-2 py-1"
type="button"
onclick={openConfirm}>Delete account</button
>
</div>
<div id="deleteConfirmModal" class="modal">
<div class="modal-content">
<div class="mb-2 inline-flex w-full justify-between">
<h2 class="font-semibold">Are you sure?</h2>
<button class="material-symbols-outlined" onclick={closeConfirm}>close</button>
</div>
<p>This will permanently delete your account. This action cannot be undone.</p>
<p>Please type "I understand" into the box below to confirm</p>
<input
type="text"
name="confirm"
id="confirm"
placeholder="I understand"
class="w-full rounded font-normal"
pattern="I understand"
/>
<div class="mt-4 flex justify-between">
<button class="danger-bg-color rounded px-2 py-1" type="submit" formaction="?/delete"
>Delete account</button
>
<button
class="separator-borders bg-color rounded px-2 py-1"
type="button"
onclick={closeConfirm}>Cancel</button
>
</div>
</div>
</div>
</form>
</div>
</div>
</div>

View File

@ -1,31 +1,49 @@
<script lang="ts">
import '../../app.css';
import { page } from '$app/stores';
import { page } from '$app/state';
import { userState } from '$lib/shared.svelte';
import { PERMISSIONS } from '$lib/consts';
let { children } = $props();
</script>
<div class="bottom-border h-10 pt-2 text-center">
<a
href="/admin/postings"
class="p-2 {$page.url.pathname === '/admin/postings'
? 'primary-underline font-bold'
: 'low-emphasis-text'}"
><span class="material-symbols-outlined align-bottom">work</span> Postings</a
>
<a
href="/admin/users"
class="p-2 {$page.url.pathname === '/admin/users'
? 'primary-underline font-bold'
: 'low-emphasis-text'}"
><span class="material-symbols-outlined align-bottom">group</span> Users</a
><a
href="/admin/tags"
class="{$page.url.pathname === '/admin/tags'
? 'primary-underline font-bold'
: 'low-emphasis-text'} p-2"
><span class="material-symbols-outlined align-bottom">sell</span> Tags</a
>
{#if (userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0}
<a
href="/admin/postings"
class="p-2 {page.url.pathname.startsWith('/admin/postings')
? 'primary-underline font-bold'
: 'low-emphasis-text low-emphasis-text-button'}"
><span class="material-symbols-outlined align-bottom">work</span> Postings</a
>
{/if}
{#if (userState.perms & PERMISSIONS.MANAGE_USERS) !== 0}
<a
href="/admin/users"
class="p-2 {page.url.pathname.startsWith('/admin/users')
? 'primary-underline font-bold'
: 'low-emphasis-text low-emphasis-text-button'}"
><span class="material-symbols-outlined align-bottom">group</span> Users</a
>
{/if}
{#if (userState.perms & PERMISSIONS.MANAGE_TAGS) !== 0}
<a
href="/admin/tags"
class="{page.url.pathname.startsWith('/admin/tags')
? 'primary-underline font-bold'
: 'low-emphasis-text low-emphasis-text-button'} p-2"
><span class="material-symbols-outlined align-bottom">sell</span> Tags</a
>
{/if}
{#if (userState.perms & PERMISSIONS.MANAGE_COMPANIES) !== 0}
<a
href="/admin/companies"
class="{page.url.pathname.startsWith('/admin/companies')
? 'primary-underline font-bold'
: 'low-emphasis-text low-emphasis-text-button'} p-2"
><span class="material-symbols-outlined align-bottom">store</span> Companies</a
>
{/if}
</div>
<div class="base-container">

View File

@ -1,7 +1,17 @@
<script>
import { onMount } from 'svelte';
import { userState } from '$lib/shared.svelte';
import { PERMISSIONS } from '$lib/consts';
onMount(() => {
window.location.href = '/admin/postings';
if ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0) {
window.location.href = '/admin/postings';
} else if ((userState.perms & PERMISSIONS.MANAGE_USERS) !== 0) {
window.location.href = '/admin/employers';
} else if ((userState.perms & PERMISSIONS.MANAGE_TAGS) !== 0) {
window.location.href = '/admin/tags';
} else if ((userState.perms & PERMISSIONS.MANAGE_COMPANIES) !== 0) {
window.location.href = '/admin/companies';
}
});
</script>

View File

View File

@ -1,13 +1,15 @@
import type { PageServerLoad } from './$types';
import { getUserPerms, getTags } from '$lib/db/index.server';
import { getTags } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { error } from '@sveltejs/kit';
import { getUserPerms } from '$lib/index.server';
export const load: PageServerLoad = async ({ cookies }) => {
export const load: PageServerLoad = async ({ cookies, url }) => {
const search = url.searchParams.get('searchUsers');
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_TAGS) > 0) {
return {
tags: await getTags()
tags: await getTags(search)
};
}
error(401, 'Unauthorized');

View File

@ -1,15 +1,21 @@
<script lang="ts">
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
let { data } = $props();
</script>
<div class="content">
<div class="elevated borders m-4 rounded">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">
Tag Management (Total: {data.tags?.length || 0})
</div>
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/admin/tags/create"
>Create New Tag</a
>Create new tag</a
>
</div>
<form action="" class="px-4">
@ -40,6 +46,7 @@
<tr>
<th class="left py-1">ID</th>
<th class="py-1">Name</th>
<th class="py-1">Created</th>
<th class="py-1"></th>
</tr>
</thead>
@ -48,7 +55,8 @@
{#each data.tags as tag}
<tr>
<td class="left">{tag.id}</td>
<td>{tag.display_name}</td>
<td>{tag.displayName}</td>
<td>{tag.createdAt?.toLocaleDateString('en-US', dateFormatOptions)}</td>
<td class="w-28 pr-1 text-end">
<a
class="hover-bg-color material-symbols-outlined icon-20 my-1 rounded p-1"

View File

@ -1,14 +1,16 @@
import type { PageServerLoad } from './$types';
import { getUserPerms, getUsers } from '$lib/db/index.server';
import { getUsers } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { error } from '@sveltejs/kit';
import { getUserPerms } from '$lib/index.server';
export const load: PageServerLoad = async ({ cookies }) => {
export const load: PageServerLoad = async ({ cookies, url }) => {
const search = url.searchParams.get('searchUsers');
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) {
return {
users: await getUsers()
users: await getUsers(search)
};
}
error(401, 'Unauthorized');
error(403, 'Unauthorized');
};

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { userPerms, employerPerms, adminPerms } from '$lib/shared.svelte';
let { data } = $props();
const dateFormatOptions: Intl.DateTimeFormatOptions = {
@ -6,17 +7,23 @@
month: 'short',
day: 'numeric'
};
// console.log(data);
function getRoleFromPerms(perms: number): string {
if (perms & adminPerms) return 'Admin';
if (perms & employerPerms) return 'Employer';
if (perms & userPerms) return 'User';
return 'None';
}
</script>
<div class="content">
<div class="elevated borders m-4 rounded">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">
User Account Management (Total: {data.users?.length || 0})
</div>
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/admin/users/create"
>Create New User</a
>Create new user</a
>
</div>
<form action="" class="px-4">
@ -45,13 +52,13 @@
<table>
<thead>
<tr>
<th class="left py-1">ID</th>
<th class="left w-16 py-1">ID</th>
<th class="py-1">Username</th>
<th class="py-1">Permissions</th>
<th class="py-1">Created</th>
<th class="py-1">Last Sign-In</th>
<th class="py-1">Active</th>
<th class="py-1"></th>
<th class="w-28 py-1"></th>
</tr>
</thead>
<tbody>
@ -60,13 +67,12 @@
<tr>
<td class="left">{user.id}</td>
<td>{user.username}</td>
<td>{user.perms}</td>
<td>{user.perms} ({getRoleFromPerms(user.perms)})</td>
<td
>{user.created_at?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>{user.createdAt?.toLocaleDateString('en-US', dateFormatOptions) || 'unknown'}</td
>
<td
>{user.last_signin?.toLocaleDateString('en-US', dateFormatOptions) ||
>{user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td class="material-symbols-outlined py-2">
@ -74,12 +80,14 @@
</td>
<td class="w-28 pr-1 text-end">
<a
class="hover-bg-color material-symbols-outlined icon-20 my-1 rounded p-1"
href="/admin/users/{user.id}">person</a
class="hover-bg-color material-symbols-outlined dull-primary-text-color icon-20 tooltip my-1 rounded p-1"
href="/admin/users/{user.id}"
>person<span class="tooltip-text font-sans text-sm">View account</span></a
>
<a
class="hover-bg-color material-symbols-outlined icon-20 my-1 ml-1 mr-8 rounded p-1"
href="/admin/users/{user.id}/edit">edit</a
class="hover-bg-color material-symbols-outlined icon-20 dull-primary-text-color tooltip my-1 ml-1 mr-8 rounded p-1"
href="/admin/users/{user.id}/edit"
>edit<span class="tooltip-text font-sans text-sm">Edit account</span></a
>
</td>
</tr>

View File

@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import { getUserWithCompany } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { error } from '@sveltejs/kit';
import { getUserPerms } from '$lib/index.server';
export const load: PageServerLoad = async ({ cookies, params }) => {
const id = parseInt(params.user);
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) {
return {
user: await getUserWithCompany(id)
};
}
error(403, 'Unauthorized');
};

View File

@ -0,0 +1,151 @@
<script lang="ts">
import type { PageProps } from './$types';
import { PERMISSIONS } from '$lib/consts';
import { adminPerms, employerPerms, userPerms } from '$lib/shared.svelte';
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
function avatarFallback(e: Event) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${data.user.fullName ? encodeURIComponent(data.user.fullName) : encodeURIComponent(data.user.username)}`;
}
function logoFallback(e: Event) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(data.user.company!.name!)}`;
}
let { data, form }: PageProps = $props();
</script>
<div class="base-container">
<div class="content flex py-4">
<div class="elevated separator-borders m-2 inline-block h-min min-w-max rounded align-top">
<div class="inline-block p-4">
<img
class="mb-2 inline-block rounded-lg"
src="/uploads/avatars/{data.user.id}.svg?timestamp=${Date.now()}"
alt="User avatar"
onerror={avatarFallback}
height="240"
width="240"
/>
{#if data.user.fullName}
<h2 class="text-center font-semibold">{data.user.fullName}</h2>
<h3 class="text-center">{data.user.username}</h3>
{/if}
{#if !data.user.fullName}
<h2 class="text-center font-semibold">{data.user.username}</h2>
{/if}
</div>
{#if data.user.email}
<div class="top-border p-3">
<span class="material-symbols-outlined align-middle">mail</span>
<a class="hover-hyperlink" href="mailto:{data.user.email}">{data.user.email}</a>
</div>
{/if}
{#if data.user.phone}
<div class="top-border p-3">
<span class="material-symbols-outlined align-middle">call</span>
<a class="hover-hyperlink" href="tel:{data.user.phone}">{data.user.phone}</a>
</div>
{/if}
{#if data.user.createdAt}
<div class="top-border p-3">
<span class="material-symbols-outlined align-middle">calendar_today</span>
Joined {data.user.createdAt.toLocaleDateString('en-US', dateFormatOptions)}
</div>
{/if}
</div>
<div class="elevated separator-borders m-2 inline-block h-min w-full rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">User Details</div>
<a
class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1"
href="/admin/users/{data.user.id}/edit">Edit user</a
>
</div>
<div class="p-3">
<div class="font-semibold">
ID: <span class="font-normal">{data.user.id}</span>
</div>
<div class="font-semibold">
Account active: <span class="material-symbols-outlined align-middle"
>{data.user.active ? 'check' : 'cancel'}</span
>
</div>
<div class="font-semibold">
Last sign-in: <span class="font-normal"
>{data.user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions)}</span
>
</div>
{#if !data.user.company?.id}
<div class="pb-2 font-semibold">
Employer company: <span class="font-normal">N/A</span>
</div>
{/if}
{#if data.user.company?.id}
<div class="font-semibold">
Employer company:
<div class="top-border mt-2 p-3">
<img
class="mb-2 inline-block rounded-lg"
src="/uploads/logos/{data.user.company.id}.svg?timestamp=${Date.now()}"
alt="Company Logo"
onerror={logoFallback}
height="32"
width="32"
/>
<div class="inline-block">
<div>{data.user.company.name}</div>
<div class="max-char-length font-normal">{data.user.company.description}</div>
</div>
</div>
</div>
{/if}
<div class="top-border pt-2 font-semibold">
Permissions: <span class="font-normal">{data.user.perms}</span>
<div class="font-normal">
{#if (data.user.perms & userPerms) !== 0}
<p class="font-semibold">User permissions:</p>
{#if (data.user.perms & PERMISSIONS.VIEW) !== 0}
<p class="pl-4">View access</p>
{/if}
{#if (data.user.perms & PERMISSIONS.APPLY_FOR_JOBS) !== 0}
<p class="pl-4">Apply for jobs</p>
{/if}
{/if}
{#if (data.user.perms & employerPerms) !== 0}
<p class="font-semibold">Employer permissions:</p>
{#if (data.user.perms & PERMISSIONS.SUBMIT_POSTINGS) !== 0}
<p class="pl-4">Submit postings</p>
{/if}
{#if (data.user.perms & PERMISSIONS.MANAGE_EMPLOYERS) !== 0}
<p class="pl-4">Manage employers (of their company)</p>
{/if}
{/if}
{#if (data.user.perms & adminPerms) !== 0}
<p class="font-semibold">Admin permissions:</p>
{#if (data.user.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0}
<p class="pl-4">Manage postings</p>
{/if}
{#if (data.user.perms & PERMISSIONS.MANAGE_USERS) !== 0}
<p class="pl-4">Manage users</p>
{/if}
{#if (data.user.perms & PERMISSIONS.MANAGE_TAGS) !== 0}
<p class="pl-4">Manage tags</p>
{/if}
{#if (data.user.perms & PERMISSIONS.MANAGE_COMPANIES) !== 0}
<p class="pl-4">Manage companies</p>
{/if}
{/if}
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,120 @@
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
import { deleteUser, getUser, updateUser } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import type { PageServerLoad } from './$types';
import { getUserPerms } from '$lib/index.server';
import { userPerms } from '$lib/shared.svelte';
export const load: PageServerLoad = async ({ cookies, params }) => {
const id = parseInt(params.user);
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) {
return {
user: await getUser(id)
};
}
error(403, 'Unauthorized');
};
export const actions: Actions = {
submit: async ({ request, cookies, params }) => {
const id = parseInt(params.user!);
const data = await request.formData();
const username = data.get('username')?.toString().trim();
let password: string | undefined | null = data.get('password')?.toString().trim();
const view = data.get('view')?.toString();
const apply = data.get('apply')?.toString();
const submitPostings = data.get('submitPostings')?.toString();
const manageEmployers = data.get('manageEmployers')?.toString();
const manageTags = data.get('manageTags')?.toString();
const managePostings = data.get('managePostings')?.toString();
const manageUsers = data.get('manageUsers')?.toString();
const accountActive = data.get('accountActive')?.toString();
let email: string | undefined | null = data.get('email')?.toString().trim();
let phone: string | undefined | null = data.get('phone')?.toString().trim();
let fullName: string | undefined | null = data.get('fullName')?.toString().trim();
let companyCode: string | undefined | null = data
.get('companyCode')
?.toString()
.toUpperCase()
.trim();
if (password === '' || password == undefined) password = null;
if (email === '' || email == undefined) email = null;
if (phone === '' || phone == undefined) phone = null;
if (fullName === '' || fullName == undefined) fullName = null;
if (companyCode === '' || companyCode == undefined) companyCode = null;
if (email && !email.includes('@')) {
return fail(400, { errorMessage: 'Invalid email' });
}
if (phone && !phone.match(/\((\d{3})\) \d{3}-\d{4}/)) {
return fail(400, { errorMessage: 'Invalid phone number' });
}
let newUserPerms = 0;
newUserPerms += PERMISSIONS.VIEW * (view === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.APPLY_FOR_JOBS * (apply === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.SUBMIT_POSTINGS * (submitPostings === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.MANAGE_EMPLOYERS * (manageEmployers === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.MANAGE_TAGS * (manageTags === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.MANAGE_POSTINGS * (managePostings === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.MANAGE_USERS * (manageUsers === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.MANAGE_COMPANIES * (manageUsers === 'on' ? 1 : 0);
const requestPerms = getUserPerms(cookies);
if (!(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_USERS) > 0)) {
return fail(403, { errorMessage: 'Unauthorized' });
} else {
if (((requestPerms | userPerms) & newUserPerms) !== newUserPerms) {
return fail(403, {
errorMessage: 'Cannot give a user higher permissions than yourself!'
});
} else {
if (username && username !== '') {
try {
await updateUser(<User>{
id: id,
username: username,
password: password,
perms: newUserPerms,
active: accountActive === 'on',
email: email,
phone: phone,
fullName: fullName,
companyCode: companyCode
});
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
return redirect(301, `/admin/users/${id}`);
} else {
return fail(400, { errorMessage: 'Missing username or password' });
}
}
}
},
delete: async ({ cookies, params }) => {
const id = parseInt(params.user!);
const userToDelete = await getUser(id);
const deletePerms = userToDelete!.perms;
const requestPerms = getUserPerms(cookies);
if (!(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_USERS) > 0)) {
return fail(403, { errorMessage: 'Unauthorized' });
} else {
if ((requestPerms & deletePerms) !== deletePerms) {
return fail(403, {
errorMessage: 'Cannot delete a user with higher permissions than yourself!'
});
} else {
try {
await deleteUser(id);
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
return redirect(301, '/admin/users');
}
}
}
};

View File

@ -0,0 +1,420 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import { PERMISSIONS } from '$lib/consts';
import { userPerms, employerPerms, adminPerms, telFormatter } from '$lib/shared.svelte';
let permsAccordions: boolean[] = [false, false, false];
let passwordVisible = $state(false);
function showPassword() {
const password = document.querySelector('input[name="password"]');
if (password) {
if (password.getAttribute('type') === 'password') {
password.setAttribute('type', 'text');
passwordVisible = true;
} else {
password.setAttribute('type', 'password');
passwordVisible = false;
}
}
}
onMount(() => {
let acc = document.getElementsByClassName('accordion');
for (let i = 0; i < acc.length; i++) {
acc[i].addEventListener('click', function (this: HTMLElement, event: Event) {
const target = event?.target as HTMLElement | null;
if (target?.tagName === 'INPUT' && (target as HTMLInputElement).type === 'checkbox') {
return; // Do nothing if it's the checkbox
}
this.classList.toggle('active');
permsAccordions[i] = !permsAccordions[i];
let panel = this.nextElementSibling as HTMLElement;
if (panel.style.display === 'block') {
panel.style.display = 'none';
} else {
panel.style.display = 'block';
}
});
let selectAllCheckbox = acc[i].querySelector('.select-all') as HTMLInputElement;
selectAllCheckbox.addEventListener('change', function () {
let checkboxes =
this.parentElement!.parentElement!.parentElement!.nextElementSibling!.querySelectorAll(
'.permCheckbox'
) as NodeListOf<HTMLInputElement>;
checkboxes.forEach((checkbox) => {
checkbox.checked = selectAllCheckbox.checked;
});
});
let permCheckboxes = acc[i].nextElementSibling!.querySelectorAll(
'.permCheckbox'
) as NodeListOf<HTMLInputElement>;
permCheckboxes.forEach((checkbox) => {
checkbox.addEventListener('change', function () {
let allChecked = true;
let someChecked = false;
permCheckboxes.forEach((cb) => {
if (cb.checked) {
someChecked = true;
} else {
allChecked = false;
}
});
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = !allChecked && someChecked;
});
});
}
const modal: HTMLElement = document.getElementById('deleteConfirmModal') as HTMLElement;
window.onclick = function (event) {
if (event.target == modal) {
modal.style.display = 'none';
}
};
document.getElementById('phone')?.addEventListener('input', function (this: HTMLInputElement) {
this.value = telFormatter(this.value);
});
});
function openConfirm() {
document.getElementById('deleteConfirmModal')!.style.display = 'block';
}
function closeConfirm() {
document.getElementById('deleteConfirmModal')!.style.display = 'none';
}
let { data, form }: PageProps = $props();
let perms = data.user!.perms;
</script>
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Update User</div>
</div>
<form method="POST" class="px-4" autocomplete="off" use:enhance>
<div class="mt-4 text-sm font-semibold">
Username <span class="text-red-500">*</span>
<input
type="text"
name="username"
id="username"
value={data.user?.username}
placeholder="Username"
class="w-full rounded font-normal"
/>
</div>
<div class="relative pt-4 text-sm font-semibold">
Password
<input
type="password"
name="password"
id="password"
placeholder="New password"
class="w-full rounded font-normal"
/>
<button
type="button"
onclick={showPassword}
class="absolute right-2.5 -translate-y-1/2 transform pt-12"
>
<span class="material-symbols-outlined"
>{passwordVisible ? 'visibility' : 'visibility_off'}</span
>
</button>
</div>
<div class="mt-4 text-sm font-semibold">
Email (optional)
<input
type="email"
name="email"
id="email"
value={data.user?.email}
placeholder="Email"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Phone (optional)
<input
type="tel"
name="phone"
id="phone"
value={data.user?.phone}
placeholder="Phone"
class="w-full rounded font-normal"
pattern="([0-9]\{3}) [0-9]\{3}-[0-9]\{3}"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Full name (optional)
<input
type="text"
name="fullName"
id="fullName"
value={data.user?.fullName}
placeholder="Full name"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Company code (optional)
<input
type="text"
name="companyCode"
id="companyCode"
placeholder="Company code"
value={data.user?.companyCode}
class="w-full rounded font-normal"
/>
</div>
<p class="low-emphasis-text">
This code can be used to associate an employer with their company. If left blank, they will
not be able to create any postings.
</p>
<div class="bg-color separator-borders mb-2 mt-4 rounded">
<button
class="accordion flex w-full place-content-between rounded p-2 text-left"
type="button"
>
<span class="flex place-items-center">
<span class="ml-1 mr-3"
><input
type="checkbox"
name="userPerms"
id="userPerms"
class="select-all"
checked={(perms & userPerms) === userPerms}
indeterminate={(perms & userPerms) !== userPerms && (perms & userPerms) !== 0}
/></span
>User Permissions
</span>
<span class="material-symbols-outlined"
>{permsAccordions[0] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
>
</button>
<div class="panel hidden p-2">
<div>
<div class="mb-1">
<label for="view" class="flex place-items-center">
<input
type="checkbox"
name="view"
id="view"
class="permCheckbox mx-1"
checked={(perms & PERMISSIONS.VIEW) !== 0}
/>
<span class="ml-2">View access</span></label
>
</div>
<div class="mb-1">
<label for="apply" class="flex place-items-center">
<input
type="checkbox"
name="apply"
id="apply"
class="permCheckbox mx-1"
checked={(perms & PERMISSIONS.APPLY_FOR_JOBS) !== 0}
/>
<span class="ml-2">Apply for jobs</span></label
>
</div>
</div>
</div>
</div>
<div class="bg-color separator-borders my-2 rounded">
<button
class="accordion flex w-full place-content-between rounded p-2 text-left"
type="button"
>
<span class="flex place-items-center">
<span class="ml-1 mr-3"
><input
type="checkbox"
name="companyPerms"
id="companyPerms"
class="select-all"
checked={(perms & employerPerms) === employerPerms}
indeterminate={(perms & employerPerms) !== employerPerms &&
(perms & employerPerms) !== 0}
/></span
>Company Permissions
</span>
<span class="material-symbols-outlined"
>{permsAccordions[1] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
>
</button>
<div class="panel hidden p-2">
<div>
<div class="mb-1">
<label for="submitPostings" class="flex place-items-center">
<input
type="checkbox"
name="submitPostings"
id="submitPostings"
checked={(perms & PERMISSIONS.SUBMIT_POSTINGS) !== 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Submit postings</span></label
>
</div>
<div class="mb-1">
<label for="manageEmployers" class="flex place-items-center">
<input
type="checkbox"
name="manageEmployers"
id="manageEmployers"
checked={(perms & PERMISSIONS.MANAGE_EMPLOYERS) !== 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage employers (within their company)</span></label
>
</div>
</div>
</div>
</div>
<div class="bg-color separator-borders mt-2 rounded">
<button
class="accordion flex w-full place-content-between rounded p-2 text-left"
type="button"
>
<span class="flex place-items-center">
<span class="ml-1 mr-3"
><input
type="checkbox"
name="adminPerms"
id="adminPerms"
class="select-all"
checked={(perms & adminPerms) === adminPerms}
indeterminate={(perms & adminPerms) !== adminPerms && (perms & adminPerms) !== 0}
/></span
>Admin Permissions
</span>
<span class="material-symbols-outlined"
>{permsAccordions[0] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
>
</button>
<div class="panel hidden p-2">
<div>
<div class="mb-1">
<label for="manageTags" class="flex place-items-center">
<input
type="checkbox"
name="manageTags"
id="manageTags"
checked={(perms & PERMISSIONS.MANAGE_TAGS) !== 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage tags</span></label
>
</div>
<div class="mb-1">
<label for="managePostings" class="flex place-items-center">
<input
type="checkbox"
name="managePostings"
id="managePostings"
checked={(perms & PERMISSIONS.MANAGE_POSTINGS) !== 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage postings</span></label
>
</div>
<div class="mb-1">
<label for="manageUsers" class="flex place-items-center">
<input
type="checkbox"
name="manageUsers"
id="manageUsers"
checked={(perms & PERMISSIONS.MANAGE_USERS) !== 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage users</span></label
>
</div>
<div class="mb-1">
<label for="manageCompanies" class="flex place-items-center">
<input
type="checkbox"
name="manageCompanies"
id="manageCompanies"
checked={(perms & PERMISSIONS.MANAGE_COMPANIES) !== 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage companies</span></label
>
</div>
</div>
</div>
</div>
<label for="accountActive" class="flex place-items-center p-2">
<input
type="checkbox"
name="accountActive"
id="accountActive"
checked={data.user?.active}
class="permCheckbox mx-1"
/>
<span class="ml-2">Account active</span></label
>
{#if form?.errorMessage}
<div class="mb-2 text-red-500">{form.errorMessage}</div>
{/if}
<div class="flex justify-between">
<button
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
type="submit"
formaction="?/submit">Update user</button
>
<button
class="danger-bg-color mb-4 mt-2 rounded px-2 py-1"
type="button"
onclick={openConfirm}>Delete user</button
>
</div>
</form>
<form id="deleteConfirmModal" class="modal" method="POST" use:enhance>
<div class="modal-content">
<div class="mb-2 inline-flex w-full justify-between">
<h2 class="font-semibold">Are you sure?</h2>
<button class="material-symbols-outlined" onclick={closeConfirm}>close</button>
</div>
<p>
This will permanently delete user <span class="font-semibold">{data.user?.username}.</span
>
</p>
<p>Please type "I understand" into the box below to confirm</p>
<input
type="text"
name="confirm"
id="confirm"
placeholder="I understand"
class="w-full rounded font-normal"
pattern="I understand"
required
/>
<div class="mt-4 flex justify-between">
<button class="danger-bg-color rounded px-2 py-1" type="submit" formaction="?/delete"
>Delete user</button
>
<button
class="separator-borders bg-color rounded px-2 py-1"
type="button"
onclick={closeConfirm}>Cancel</button
>
</div>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,88 @@
import { type Actions, fail, redirect } from '@sveltejs/kit';
import { createUser } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { getUserPerms } from '$lib/index.server';
export const actions: Actions = {
submit: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username')?.toString().trim();
const password = data.get('password')?.toString().trim();
const view = data.get('view')?.toString();
const apply = data.get('apply')?.toString();
const submitPostings = data.get('submitPostings')?.toString();
const manageEmployers = data.get('manageEmployers')?.toString();
const manageTags = data.get('manageTags')?.toString();
const managePostings = data.get('managePostings')?.toString();
const manageUsers = data.get('manageUsers')?.toString();
const accountActive = data.get('accountActive')?.toString();
let email: string | undefined | null = data.get('email')?.toString().trim();
let phone: string | undefined | null = data.get('phone')?.toString().trim();
let fullName: string | undefined | null = data.get('fullName')?.toString().trim();
let companyCode: string | undefined | null = data
.get('companyCode')
?.toString()
.toUpperCase()
.trim();
if (email === '' || email == undefined) email = null;
if (phone === '' || phone == undefined) phone = null;
if (fullName === '' || fullName == undefined) fullName = null;
if (companyCode === '' || companyCode == undefined) companyCode = null;
if (email && !email.includes('@')) {
return fail(400, { errorMessage: 'Invalid email' });
}
if (phone && !phone.match(/[0-9]{3}-[0-9]{3}-[0-9]{4}/)) {
return fail(400, { errorMessage: 'Invalid phone number' });
}
let newUserPerms = 0;
newUserPerms += PERMISSIONS.VIEW * (view === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.APPLY_FOR_JOBS * (apply === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.SUBMIT_POSTINGS * (submitPostings === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.MANAGE_EMPLOYERS * (manageEmployers === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.MANAGE_TAGS * (manageTags === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.MANAGE_POSTINGS * (managePostings === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.MANAGE_USERS * (manageUsers === 'on' ? 1 : 0);
newUserPerms += PERMISSIONS.MANAGE_COMPANIES * (manageUsers === 'on' ? 1 : 0);
const requestPerms = getUserPerms(cookies);
if (!(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_USERS) > 0)) {
return fail(403, { errorMessage: 'You cannot preform this action!' });
} else {
if ((requestPerms & newUserPerms) !== newUserPerms) {
return fail(403, {
errorMessage: 'Cannot create a user with higher permissions than yourself!'
});
} else {
if (username && password && username !== '' && password !== '') {
if (password.length < 8) {
return fail(400, { errorMessage: 'Password must be at least 8 characters' });
}
let id = -1;
try {
id = await createUser(<User>{
username: username,
password: password,
perms: newUserPerms,
active: accountActive === 'on',
email: email,
phone: phone,
fullName: fullName,
companyCode: companyCode
});
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
if (id !== -1) {
return redirect(301, `/admin/users/${id}`);
}
} else {
return fail(400, { errorMessage: 'Missing username or password' });
}
}
}
}
};

View File

@ -0,0 +1,338 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import { telFormatter } from '$lib/shared.svelte';
let permsAccordions: boolean[] = [false, false, false];
let passwordVisible = $state(false);
function showPassword() {
const password = document.querySelector('input[name="password"]');
if (password) {
if (password.getAttribute('type') === 'password') {
password.setAttribute('type', 'text');
passwordVisible = true;
} else {
password.setAttribute('type', 'password');
passwordVisible = false;
}
}
}
onMount(() => {
let acc = document.getElementsByClassName('accordion');
for (let i = 0; i < acc.length; i++) {
acc[i].addEventListener('click', function (this: HTMLElement, event: Event) {
const target = event?.target as HTMLElement | null;
if (target?.tagName === 'INPUT' && (target as HTMLInputElement).type === 'checkbox') {
return; // Do nothing if it's the checkbox
}
this.classList.toggle('active');
permsAccordions[i] = !permsAccordions[i];
let panel = this.nextElementSibling as HTMLElement;
if (panel.style.display === 'block') {
panel.style.display = 'none';
} else {
panel.style.display = 'block';
}
});
let selectAllCheckbox = acc[i].querySelector('.select-all') as HTMLInputElement;
// console.log(selectAllCheckbox);
selectAllCheckbox.addEventListener('change', function () {
let checkboxes =
this.parentElement!.parentElement!.parentElement!.nextElementSibling!.querySelectorAll(
'.permCheckbox'
) as NodeListOf<HTMLInputElement>;
// console.log(checkboxes);
checkboxes.forEach((checkbox) => {
checkbox.checked = selectAllCheckbox.checked;
});
});
let permCheckboxes = acc[i].nextElementSibling!.querySelectorAll(
'.permCheckbox'
) as NodeListOf<HTMLInputElement>;
permCheckboxes.forEach((checkbox) => {
checkbox.addEventListener('change', function () {
let allChecked = true;
let someChecked = false;
permCheckboxes.forEach((cb) => {
if (cb.checked) {
someChecked = true;
} else {
allChecked = false;
}
});
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = !allChecked && someChecked;
});
});
}
document.getElementById('phone')?.addEventListener('input', function (this: HTMLInputElement) {
this.value = telFormatter(this.value);
});
});
let { data, form }: PageProps = $props();
</script>
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Create new user</div>
</div>
<form method="POST" class="px-4" autocomplete="off" use:enhance>
<div class="mt-4 text-sm font-semibold">
Username <span class="text-red-500">*</span>
<input
type="text"
name="username"
id="username"
placeholder="Username"
class="w-full rounded font-normal"
required
/>
</div>
<div class="relative mt-4 text-sm font-semibold">
Password <span class="text-red-500">*</span>
<input
type="password"
name="password"
id="password"
placeholder="Password"
class="w-full rounded font-normal"
required
/>
<button
type="button"
onclick={showPassword}
class="absolute right-2.5 -translate-y-1/2 transform pt-12"
>
<span class="material-symbols-outlined"
>{passwordVisible ? 'visibility' : 'visibility_off'}</span
>
</button>
</div>
<div class="mt-4 text-sm font-semibold">
Email (optional)
<input
type="email"
name="email"
id="email"
placeholder="Email"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Phone (optional)
<input
type="tel"
name="phone"
id="phone"
placeholder="Phone"
class="w-full rounded font-normal"
pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Full name (optional)
<input
type="text"
name="fullName"
id="fullName"
placeholder="Full Name"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Company code (optional)
<input
type="text"
name="companyCode"
id="companyCode"
placeholder="Company code"
class="w-full rounded font-normal"
/>
</div>
<p class="low-emphasis-text">
This code can be used to associate an employer with their company. If left blank, they will
not be able to create any postings.
</p>
<div class="bg-color separator-borders mb-2 mt-4 rounded">
<button
class="accordion flex w-full place-content-between rounded p-2 text-left"
type="button"
>
<span class="flex place-items-center">
<span class="ml-1 mr-3"
><input
type="checkbox"
name="userPerms"
id="userPerms"
class="select-all"
indeterminate={true}
/></span
>User Permissions
</span>
<span class="material-symbols-outlined"
>{permsAccordions[0] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
>
</button>
<div class="panel hidden p-2">
<div>
<div class="mb-1">
<label for="view" class="flex place-items-center">
<input
type="checkbox"
name="view"
id="view"
checked={true}
class="permCheckbox mx-1"
/>
<span class="ml-2">View access</span></label
>
</div>
<div class="mb-1">
<label for="apply" class="flex place-items-center">
<input type="checkbox" name="apply" id="apply" class="permCheckbox mx-1" />
<span class="ml-2">Apply for jobs</span></label
>
</div>
</div>
</div>
</div>
<div class="bg-color separator-borders my-2 rounded">
<button
class="accordion flex w-full place-content-between rounded p-2 text-left"
type="button"
>
<span class="flex place-items-center">
<span class="ml-1 mr-3"
><input
type="checkbox"
name="companyPerms"
id="companyPerms"
class="select-all"
/></span
>Company Permissions
</span>
<span class="material-symbols-outlined"
>{permsAccordions[1] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
>
</button>
<div class="panel hidden p-2">
<div>
<div class="mb-1">
<label for="submitPostings" class="flex place-items-center">
<input
type="checkbox"
name="submitPostings"
id="submitPostings"
class="permCheckbox mx-1"
/>
<span class="ml-2">Submit postings</span></label
>
</div>
<div class="mb-1">
<label for="manageEmployers" class="flex place-items-center">
<input
type="checkbox"
name="manageEmployers"
id="manageEmployers"
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage employers (within their company)</span></label
>
</div>
</div>
</div>
</div>
<div class="bg-color separator-borders mt-2 rounded">
<button
class="accordion flex w-full place-content-between rounded p-2 text-left"
type="button"
>
<span class="flex place-items-center">
<span class="ml-1 mr-3"
><input type="checkbox" name="adminPerms" id="adminPerms" class="select-all" /></span
>Admin Permissions
</span>
<span class="material-symbols-outlined"
>{permsAccordions[0] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
>
</button>
<div class="panel hidden p-2">
<div>
<div class="mb-1">
<label for="manageTags" class="flex place-items-center">
<input
type="checkbox"
name="manageTags"
id="manageTags"
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage tags</span></label
>
</div>
<div class="mb-1">
<label for="managePostings" class="flex place-items-center">
<input
type="checkbox"
name="managePostings"
id="managePostings"
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage postings</span></label
>
</div>
<div class="mb-1">
<label for="manageUsers" class="flex place-items-center">
<input
type="checkbox"
name="manageUsers"
id="manageUsers"
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage users</span></label
>
</div>
<div class="mb-1">
<label for="manageCompanies" class="flex place-items-center">
<input
type="checkbox"
name="manageCompanies"
id="manageCompanies"
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage companies</span></label
>
</div>
</div>
</div>
</div>
<label for="accountActive" class="flex place-items-center p-2">
<input
type="checkbox"
name="accountActive"
id="accountActive"
class="permCheckbox mx-1"
checked
/>
<span class="ml-2">Account active</span></label
>
{#if form?.errorMessage}
<div class="mb-2 text-red-500">{form.errorMessage}</div>
{/if}
<button
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
type="submit"
formaction="?/submit">Create user</button
>
</form>
</div>
</div>

View File

@ -0,0 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte';
onMount(() => {
if (document.cookie.includes('jwt=')) {
window.location.href = '/account';
}
});
</script>
<div class="signin-container place-items-center pt-8">
<div class="elevated separator-borders bg content rounded-md p-8">
<h1 class=" text-weight-semibold mb-4 text-center">Register</h1>
<h3>Are you an applicant or an employer?</h3>
<a
class="primary-bg-color mb-4 mt-6 block w-full rounded px-2 py-2 text-center"
href="/register/user">Applicant</a
>
<div class="low-emphasis-text text-center text-3xl">OR</div>
<a
href="/register/employer"
class="primary-bg-color mb-2 mt-4 block w-full rounded px-2 py-2 text-center">Employer</a
>
<a href="/signin" class="low-emphasis-text-button">I already have an account.</a>
</div>
</div>

View File

@ -0,0 +1,57 @@
import * as dotenv from 'dotenv';
import { type Actions, type Cookies, fail, redirect } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { createUser } from '$lib/db/index.server';
dotenv.config({ path: '.env' });
function setJWT(cookies: Cookies, username: string, perms: number) {
const payload = {
username: username,
perms: perms
};
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const maxAge = 60 * 60 * 24 * 30; // 30 days
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false });
console.log(cookies.get('jwt'));
}
export const actions: Actions = {
register: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username')?.toString().trim();
const password = data.get('password')?.toString().trim();
const confirmPassword = data.get('confirmPassword')?.toString().trim();
if (
username &&
password &&
confirmPassword &&
username !== '' &&
password !== '' &&
confirmPassword !== ''
) {
if (password.length < 8) {
return fail(400, { errorMessage: 'Password must be at least 8 characters' });
}
if (password === confirmPassword) {
try {
await createUser(<User>{ username: username, password: password, perms: 3 });
} catch (err) {
return fail(400, { errorMessage: `Internal Server Error: ${err}` });
}
setJWT(cookies, username, 1);
throw redirect(303, '/');
} else {
return fail(400, { errorMessage: 'Passwords do not match' });
}
} else {
return fail(400, { errorMessage: 'Missing username or password' });
}
}
};

View File

@ -0,0 +1,55 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
onMount(() => {
if (document.cookie.includes('jwt=')) {
window.location.href = '/account';
}
});
// receive form data from server
let { data, form }: PageProps = $props();
</script>
<div class="signin-container place-items-center pt-8">
<div class="elevated separator-borders bg content rounded-md p-8">
<h1 class="text-weight-semibold mb-4 text-center">Register</h1>
<p>This is for employers only!</p>
<form method="POST" class="arrange-vertically" use:enhance>
<input
class="input-field my-4 w-full"
type="text"
placeholder="Username"
name="username"
required
/>
<input
type="password"
class="input-field my-4 w-full"
placeholder="Password"
name="password"
required
/>
<input
type="password"
class="input-field mt-4 w-full"
placeholder="Confirm password"
name="confirmPassword"
required
/>
{#if form?.errorMessage}
<div class="my-2 text-red-500">{form.errorMessage}</div>
{/if}
<button
class="primary-bg-color mt-8 w-full rounded px-2 py-2"
type="submit"
formaction="?/register">Create account</button
>
<a href="/signin" class="low-emphasis-text-button mt-2">I already have an account.</a>
</form>
</div>
</div>

View File

@ -0,0 +1,72 @@
import * as dotenv from 'dotenv';
import { type Actions, type Cookies, fail, redirect } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { createUser } from '$lib/db/index.server';
dotenv.config({ path: '.env' });
function setJWT(cookies: Cookies, username: string, perms: number) {
const payload = {
username: username,
perms: perms
};
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const maxAge = 60 * 60 * 24 * 30; // 30 days
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false });
console.log(cookies.get('jwt'));
}
export const actions: Actions = {
register: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username')?.toString().trim();
const password = data.get('password')?.toString().trim();
const confirmPassword = data.get('confirmPassword')?.toString().trim();
let email: string | undefined | null = data.get('email')?.toString().trim();
let fullName: string | undefined | null = data.get('fullName')?.toString().trim();
if (email === '') email = null;
if (fullName === '') fullName = null;
if (email && !email.includes('@')) {
return fail(400, { errorMessage: 'Invalid email' });
}
if (
username &&
password &&
confirmPassword &&
username !== '' &&
password !== '' &&
confirmPassword !== ''
) {
if (password.length < 8) {
return fail(400, { errorMessage: 'Password must be at least 8 characters' });
}
if (password === confirmPassword) {
try {
await createUser(<User>{
username: username,
password: password,
perms: 3,
active: true,
email: email,
fullName: fullName
});
} catch (err) {
return fail(400, { errorMessage: `Internal Server Error: ${err}` });
}
setJWT(cookies, username, 1);
throw redirect(303, '/');
} else {
return fail(400, { errorMessage: 'Passwords do not match' });
}
} else {
return fail(400, { errorMessage: 'Missing username or password' });
}
}
};

View File

@ -0,0 +1,85 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
onMount(() => {
if (document.cookie.includes('jwt=')) {
window.location.href = '/account';
}
});
// receive form data from server
let { data, form }: PageProps = $props();
</script>
<div class="signin-container place-items-center pt-8">
<div class="elevated separator-borders bg content rounded-md p-8">
<h1 class="text-weight-semibold mb-4 text-center">Register</h1>
<p>Create your account. Its free and only takes a minute!</p>
<form method="POST" class="arrange-vertically" use:enhance>
<div class="mt-4 text-sm font-semibold">
Username <span class="text-red-500">*</span>
<input
type="text"
name="username"
id="username"
placeholder="Username"
class="input-field w-full font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Email (optional)
<input
type="text"
name="email"
id="email"
placeholder="Email"
class="input-field w-full font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Full name (optional)
<input
type="text"
name="fullName"
id="fullName"
placeholder="Full name"
class="input-field w-full font-normal"
/>
</div>
<div class="relative mt-4 text-sm font-semibold">
Password <span class="text-red-500">*</span>
<input
type="password"
class="input-field w-full font-normal"
placeholder="Password"
name="password"
required
/>
</div>
<div class="relative mt-4 text-sm font-semibold">
Confirm password <span class="text-red-500">*</span>
<input
type="password"
class="input-field w-full font-normal"
placeholder="Password"
name="confirmPassword"
required
/>
</div>
{#if form?.errorMessage}
<div class="my-2 text-red-500">{form.errorMessage}</div>
{/if}
<button
class="primary-bg-color mt-8 w-full rounded px-2 py-2"
type="submit"
formaction="?/register">Create account</button
>
<a href="/signin" class="low-emphasis-text-button mt-2">I already have an account.</a>
</form>
</div>
</div>

View File

@ -1,14 +1,15 @@
import { checkUserCreds, createUser } from '$lib/db/index.server';
import { checkUserCreds, createUser, updateLastSignin } from '$lib/db/index.server';
import { fail, redirect, type Actions, type Cookies } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env' });
function setJWT(cookies: Cookies, username: string, perms: number) {
function setJWT(cookies: Cookies, user: User) {
const payload = {
username: username,
perms: perms
username: user.username,
perms: user.perms,
id: user.id
};
if (process.env.JWT_SECRET === undefined) {
@ -18,42 +19,31 @@ function setJWT(cookies: Cookies, username: string, perms: number) {
const maxAge = 60 * 60 * 24 * 30; // 30 days
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false });
console.log(cookies.get('jwt'));
}
export const actions: Actions = {
register: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username')?.toString();
const password = data.get('password')?.toString();
if (username && password) {
try {
await createUser(username, password);
} catch (err) {
return fail(400, { errorMessage: `Internal Server Error: ${err}` });
}
} else {
return fail(400, { errorMessage: 'Missing username or password' });
}
},
signin: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username')?.toString();
const password = data.get('password')?.toString();
const username = data.get('username')?.toString().trim();
const password = data.get('password')?.toString().trim();
if (username && password) {
const perms = await checkUserCreds(username, password);
if (username && password && username !== '' && password !== '') {
const user: User | null = await checkUserCreds(username, password);
if (perms === -1) {
return fail(401, { errorMessage: 'Invalid username or password' });
if (!user) {
return fail(400, { errorMessage: 'Invalid username or password' });
}
setJWT(cookies, username, perms);
if (!user.active) {
return fail(400, {
errorMessage:
'Account is disabled. Please contact your admin if you think this is a mistake.'
});
}
setJWT(cookies, user);
await updateLastSignin(username);
// redirect to home page
// return { perms: perms };
throw redirect(303, '/');
} else {
return fail(400, { errorMessage: 'Missing username or password' });

View File

@ -1,6 +1,7 @@
<script lang="ts">
import type { ActionData } from './$types';
import type { PageProps } from './$types';
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
onMount(() => {
if (document.cookie.includes('jwt=')) {
@ -22,33 +23,38 @@
}
}
}
// receive form data from server
let form: ActionData;
let { data, form }: PageProps = $props();
</script>
<div class="signin-container place-items-center pt-8">
<div class="elevated content rounded-md p-8">
<h1 class="is-size-3 has-text-weight-semibold my-4">Welcome Back!</h1>
<form method="POST" class="arrange-vertically">
<input
class="input-field my-2 w-full"
type="text"
placeholder="Username"
name="username"
required
/>
<div class="relative w-full">
<div class="separator-borders elevated content rounded-md p-8">
<h1 class="text-weight-semibold mb-4 text-center">Welcome Back!</h1>
<form method="POST" class="arrange-vertically" use:enhance>
<div class="bg-color my-2 rounded">
<input
type={passwordVisible ? 'text' : 'password'}
class="input-field my-2 w-full pr-10"
placeholder="Password"
name="password"
class="input-field w-full"
type="text"
placeholder="Username"
name="username"
required
/>
</div>
<div class="relative w-full">
<div class="bg-color mt-4 rounded">
<input
type={passwordVisible ? 'text' : 'password'}
class="input-field w-full pr-10"
placeholder="Password"
name="password"
required
/>
</div>
<button
type="button"
onclick={showPassword}
class="absolute right-2.5 top-8 -translate-y-1/2 transform"
class="absolute right-2.5 top-6 -translate-y-1/2 transform"
>
<span class="material-symbols-outlined"
>{passwordVisible ? 'visibility' : 'visibility_off'}</span
@ -56,33 +62,18 @@
</button>
</div>
<!-- TODO: fix -->
{#if form?.errorMessage}
<div class="has-text-danger my-2">{form.errorMessage}</div>
<div class="my-2 text-red-500">{form.errorMessage}</div>
{/if}
<button
class="primary-bg-color mt-6 w-full rounded px-2 py-2"
class="primary-bg-color mt-8 w-full rounded px-2 py-2"
type="submit"
formaction="?/signin">Sign In</button
>
<a href="/register" class="low-emphasis-text mt-2">Don't have an account? Register here.</a>
<a href="/register" class="low-emphasis-text-button mt-2"
>Don't have an account? Register here.</a
>
</form>
</div>
</div>
<!--<div class="container">-->
<!-- <h1 class="is-size-3 has-text-weight-semibold my-4">Sign In or Register</h1>-->
<!-- <form method="POST">-->
<!-- <input class="input my-2" type="text" placeholder="Username" name="username" required />-->
<!-- <input class="input my-2" type="password" placeholder="Password" name="password" required />-->
<!-- &lt;!&ndash; display error message &ndash;&gt;-->
<!-- {#if form?.errorMessage}-->
<!-- <div class="has-text-danger my-2">{form.errorMessage}</div>-->
<!-- {/if}-->
<!-- <button class="button mr-3 mt-4" type="submit" formaction="?/register">Register</button>-->
<!-- <button class="button is-primary mt-4" type="submit" formaction="?/login">Sign In</button>-->
<!-- </form>-->
<!--</div>-->

View File

@ -0,0 +1,14 @@
import { userState } from '$lib/shared.svelte';
export const getCookieValue = (name: String) =>
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
export function updateUserState() {
const JWT = getCookieValue('jwt');
if (JWT !== '') {
const state = JSON.parse(atob(JWT.split('.')[1]));
userState.perms = state.perms;
userState.username = state.username;
userState.id = state.id;
}
}