dev
This commit is contained in:
parent
76c2680c60
commit
be83b7570d
3
.gitignore
vendored
3
.gitignore
vendored
@ -26,3 +26,6 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
# Postgres
|
# Postgres
|
||||||
postgresql
|
postgresql
|
||||||
|
|
||||||
|
# User uploads
|
||||||
|
uploads
|
||||||
|
|||||||
37
README.md
37
README.md
@ -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
|
- [ ] Separation of companies and employers
|
||||||
# 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.
|
|
||||||
1492
package-lock.json
generated
1492
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -16,10 +16,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.3",
|
"@eslint/compat": "^1.2.3",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "^2.16.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/node-fetch": "^2.6.12",
|
||||||
"eslint": "^9.7.0",
|
"eslint": "^9.7.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.36.0",
|
"eslint-plugin-svelte": "^2.36.0",
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
## Table of Permissions
|
## Table of Permissions
|
||||||
|
|
||||||
| Permission Value | Description |
|
| Permission Value | Description |
|
||||||
|------------------|------------------------|
|
|---------------------|------------------|
|
||||||
| `00000001` | View postings |
|
| `0b00000001`(bit 0) | View access |
|
||||||
| `00000010` | View account |
|
| `0b00000010`(bit 1) | Apply for jobs |
|
||||||
| `00000100` | Apply for jobs |
|
| `0b00000100`(bit 2) | Submit postings |
|
||||||
| `00001000` | Submit postings |
|
| `0b00001000`(bit 3) | Manage employers |
|
||||||
| `00010000` | Manage tags |
|
| `0b00010000`(bit 4) | Manage tags |
|
||||||
| `00100000` | Manage postings |
|
| `0b00100000`(bit 5) | Manage postings |
|
||||||
| `01000000` | Manage users |
|
| `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).
|
||||||
169
src/app.css
169
src/app.css
@ -13,18 +13,20 @@
|
|||||||
--dull-primary-color: #51aaf0;
|
--dull-primary-color: #51aaf0;
|
||||||
--elevated-bg-color: #ffffff;
|
--elevated-bg-color: #ffffff;
|
||||||
--bg-accent-color: #f4f4f4;
|
--bg-accent-color: #f4f4f4;
|
||||||
|
--danger-color: #ff2d2f;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
--text-color: #f4f4f4;
|
--text-color: #f4f4f4;
|
||||||
--bg-color: #080808;
|
--bg-color: #0c0c0c;
|
||||||
--hover-bg-color: #1f2937;
|
--hover-bg-color: #1a1c1c;
|
||||||
--separator-line-color: #1a2029;
|
--separator-line-color: #1a2029;
|
||||||
--low-emphasis-text-color: #999999;
|
--low-emphasis-text-color: #999999;
|
||||||
--primary-color: #1F96F3;
|
--primary-color: #1F96F3;
|
||||||
--dull-primary-color: #1569ab;
|
--dull-primary-color: #1569ab;
|
||||||
--elevated-bg-color: #0c0c0d;
|
--elevated-bg-color: #101011;
|
||||||
--bg-accent-color: #202020;
|
--bg-accent-color: #202020;
|
||||||
|
--danger-color: #ff1d1f;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@ -37,9 +39,8 @@ h1 {
|
|||||||
@apply text-4xl
|
@apply text-4xl
|
||||||
}
|
}
|
||||||
|
|
||||||
.elevated {
|
.bg-color {
|
||||||
background-color: var(--separator-line-color);
|
background-color: var(--bg-color);
|
||||||
@apply shadow-lg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-bg-color:hover {
|
.hover-bg-color:hover {
|
||||||
@ -50,7 +51,11 @@ h1 {
|
|||||||
color: var(--low-emphasis-text-color);
|
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);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,18 +89,23 @@ h1 {
|
|||||||
'opsz' 20
|
'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);
|
background-color: var(--bg-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border: 1px solid var(--separator-line-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);
|
outline: 0 solid var(--text-color);
|
||||||
border: 1px solid var(--primary-color);
|
border: 1px solid var(--primary-color);
|
||||||
box-shadow: 0 0 0 0 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 {
|
.input-field {
|
||||||
border: 1px solid var(--separator-line-color);
|
border: 1px solid var(--separator-line-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@ -181,7 +191,7 @@ th.left, td.left {
|
|||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.borders {
|
.separator-borders {
|
||||||
border: 1px solid var(--separator-line-color);
|
border: 1px solid var(--separator-line-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,3 +208,142 @@ th.left, td.left {
|
|||||||
background-color: var(--dull-primary-color);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
export const PERMISSIONS = {
|
export const PERMISSIONS = {
|
||||||
VIEW_POSTINGS: 0b00000001,
|
VIEW: 0b00000001,
|
||||||
VIEW_ACCOUNT: 0b00000010,
|
APPLY_FOR_JOBS: 0b0000010,
|
||||||
APPLY_FOR_JOBS: 0b00000100,
|
SUBMIT_POSTINGS: 0b0000100,
|
||||||
SUBMIT_POSTINGS: 0b00001000,
|
MANAGE_EMPLOYERS: 0b00001000,
|
||||||
MANAGE_TAGS: 0b00010000,
|
MANAGE_TAGS: 0b00010000,
|
||||||
MANAGE_POSTINGS: 0b00100000,
|
MANAGE_POSTINGS: 0b00100000,
|
||||||
MANAGE_USERS: 0b01000000
|
MANAGE_USERS: 0b01000000,
|
||||||
|
MANAGE_COMPANIES: 0b10000000
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,100 +1,192 @@
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import sql from '$lib/db/db.server';
|
import sql from '$lib/db/db.server';
|
||||||
import type { Cookies } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import jwt from 'jsonwebtoken';
|
import { saveAvatar } from '$lib/index.server';
|
||||||
|
|
||||||
export async function createUser(username: string, password: string): Promise<void> {
|
export async function createUser(user: User): Promise<number> {
|
||||||
const password_hash: string = await bcrypt.hash(password, 12);
|
const password_hash: string = await bcrypt.hash(user.password!, 12);
|
||||||
const timestamp = new Date(Date.now()).toISOString();
|
|
||||||
|
|
||||||
console.log(timestamp);
|
|
||||||
|
|
||||||
const response = await sql`
|
const response = await sql`
|
||||||
INSERT INTO users (username, password_hash, perms, created_at, last_signin, active)
|
INSERT INTO users (username, password_hash, perms, created_at, last_signin, active, email, phone, full_name, company_code)
|
||||||
VALUES (${username}, ${password_hash}, 3, ${timestamp}, ${timestamp}, ${true});
|
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`
|
const [user] = await sql`
|
||||||
SELECT password_hash, perms
|
SELECT id, password_hash, perms, active
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = ${username}
|
WHERE username = ${username}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return -1;
|
return null;
|
||||||
}
|
}
|
||||||
if (await bcrypt.compare(password, user.password_hash)) {
|
if (await bcrypt.compare(password, user.password_hash)) {
|
||||||
return user['perms'];
|
return <User>{ id: user.id, perms: user.perms, active: user.active };
|
||||||
}
|
}
|
||||||
return -1;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// should require MANAGE_USERS permission
|
// should require MANAGE_USERS permission
|
||||||
export async function getUsers(): Promise<User[]> {
|
export async function getUsers(searchQuery: string | null = null): Promise<User[]> {
|
||||||
const users = await sql<
|
const users = await sql`
|
||||||
{
|
SELECT id,
|
||||||
id: number;
|
username,
|
||||||
username: string;
|
perms,
|
||||||
perms: number;
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
created_at: Date;
|
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
|
||||||
last_signin: Date;
|
active,
|
||||||
active: boolean;
|
email,
|
||||||
}[]
|
phone,
|
||||||
>`
|
full_name AS "fullName",
|
||||||
SELECT id, username, perms,
|
company_id
|
||||||
created_at AT TIME ZONE 'UTC' AS created_at,
|
FROM users
|
||||||
last_signin AT TIME ZONE 'UTC' AS last_signin,
|
WHERE username ILIKE ${searchQuery ? `%${searchQuery}%` : '%'};
|
||||||
active
|
`;
|
||||||
FROM users;
|
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
|
// should require MANAGE_TAGS permission
|
||||||
export async function getTags(): Promise<Tag[]> {
|
export async function getTags(searchQuery: string | null): Promise<Tag[]> {
|
||||||
const tags = await sql<
|
return sql<Tag[]>`
|
||||||
{
|
SELECT id, display_name as "displayName", created_at AT TIME ZONE 'UTC' AS "createdAt"
|
||||||
id: number;
|
FROM tags
|
||||||
display_name: string;
|
WHERE display_name ILIKE ${searchQuery ? `%${searchQuery}%` : '%'};
|
||||||
}[]
|
|
||||||
>`
|
|
||||||
SELECT id, display_name
|
|
||||||
FROM tags;
|
|
||||||
`;
|
`;
|
||||||
return tags.map(
|
}
|
||||||
(tag): Tag => ({
|
|
||||||
id: tag.id,
|
export async function updateLastSignin(username: string): Promise<void> {
|
||||||
display_name: tag.display_name
|
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
53
src/lib/index.server.ts
Normal 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');
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,28 @@
|
|||||||
interface User {
|
interface User {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
username: string;
|
username: string;
|
||||||
|
password: string | null;
|
||||||
perms: number;
|
perms: number;
|
||||||
created_at: Date | null;
|
createdAt: Date | null;
|
||||||
last_signin: Date | null;
|
lastSignIn: Date | null;
|
||||||
active: boolean | null;
|
active: boolean | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
fullName: string | null;
|
||||||
|
company: Company | null;
|
||||||
|
companyCode: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Tag {
|
interface Tag {
|
||||||
id: number;
|
id: number;
|
||||||
display_name: string;
|
displayName: string;
|
||||||
|
createdAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Company {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
website: string;
|
||||||
|
createdAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { userState } from '$lib/shared.svelte';
|
import { userState } from '$lib/shared.svelte';
|
||||||
import { PERMISSIONS } from '$lib/consts';
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { updateUserState } from './utils.client';
|
||||||
|
|
||||||
let currentTheme: string = $state('');
|
let currentTheme: string = $state('');
|
||||||
|
|
||||||
@ -12,15 +13,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setTheme(theme: string) {
|
function setTheme(theme: string) {
|
||||||
const one_year = 60 * 60 * 24 * 365;
|
const oneYear = 60 * 60 * 24 * 365;
|
||||||
document.cookie = `theme=${theme}; max-age=${one_year}; path=/`;
|
document.cookie = `theme=${theme}; max-age=${oneYear}; path=/`;
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
currentTheme = theme;
|
currentTheme = theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCookieValue = (name: String) =>
|
|
||||||
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const savedTheme = document.documentElement.getAttribute('data-theme');
|
const savedTheme = document.documentElement.getAttribute('data-theme');
|
||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
@ -31,18 +29,25 @@
|
|||||||
setTheme(currentTheme);
|
setTheme(currentTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
const JWT = getCookieValue('jwt');
|
updateUserState();
|
||||||
if (JWT !== '') {
|
|
||||||
userState.perms = JSON.parse(atob(JWT.split('.')[1])).perms;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
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">
|
<div class="bottom-border flex h-14 justify-between p-3 align-middle">
|
||||||
@ -57,12 +62,24 @@
|
|||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="/about" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">About</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>
|
<a href="/listings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Listings</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if (userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0}
|
{#if (userState.perms & PERMISSIONS.VIEW) !== 0}
|
||||||
<a href="/admin/postings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm"
|
<a href="/companies" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Companies</a>
|
||||||
>Administration</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}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
@ -72,13 +89,9 @@
|
|||||||
{currentTheme === 'light' ? 'light_mode' : 'dark_mode'}
|
{currentTheme === 'light' ? 'light_mode' : 'dark_mode'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onclick={() => (window.location.href = userState.id !== null ? '/account' : '/signin')}>
|
||||||
onclick={() =>
|
|
||||||
(window.location.href =
|
|
||||||
(userState.perms & PERMISSIONS.VIEW_ACCOUNT) !== 0 ? '/account' : '/signin')}
|
|
||||||
>
|
|
||||||
<span class="material-symbols-outlined hover-bg-color rounded-full p-1">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,2 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { updateUserState } from './utils.client';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
updateUserState();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
10
src/routes/account/+page.server.ts
Normal file
10
src/routes/account/+page.server.ts
Normal 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) };
|
||||||
|
};
|
||||||
@ -1,9 +1,132 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!document.cookie.includes('jwt=')) {
|
if (!document.cookie.includes('jwt=')) {
|
||||||
window.location.href = '/signin';
|
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>
|
</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>
|
||||||
|
|||||||
63
src/routes/account/settings/+page.server.ts
Normal file
63
src/routes/account/settings/+page.server.ts
Normal 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}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
198
src/routes/account/settings/+page.svelte
Normal file
198
src/routes/account/settings/+page.svelte
Normal 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>
|
||||||
@ -1,31 +1,49 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../../app.css';
|
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();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bottom-border h-10 pt-2 text-center">
|
<div class="bottom-border h-10 pt-2 text-center">
|
||||||
|
{#if (userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0}
|
||||||
<a
|
<a
|
||||||
href="/admin/postings"
|
href="/admin/postings"
|
||||||
class="p-2 {$page.url.pathname === '/admin/postings'
|
class="p-2 {page.url.pathname.startsWith('/admin/postings')
|
||||||
? 'primary-underline font-bold'
|
? 'primary-underline font-bold'
|
||||||
: 'low-emphasis-text'}"
|
: 'low-emphasis-text low-emphasis-text-button'}"
|
||||||
><span class="material-symbols-outlined align-bottom">work</span> Postings</a
|
><span class="material-symbols-outlined align-bottom">work</span> Postings</a
|
||||||
>
|
>
|
||||||
|
{/if}
|
||||||
|
{#if (userState.perms & PERMISSIONS.MANAGE_USERS) !== 0}
|
||||||
<a
|
<a
|
||||||
href="/admin/users"
|
href="/admin/users"
|
||||||
class="p-2 {$page.url.pathname === '/admin/users'
|
class="p-2 {page.url.pathname.startsWith('/admin/users')
|
||||||
? 'primary-underline font-bold'
|
? 'primary-underline font-bold'
|
||||||
: 'low-emphasis-text'}"
|
: 'low-emphasis-text low-emphasis-text-button'}"
|
||||||
><span class="material-symbols-outlined align-bottom">group</span> Users</a
|
><span class="material-symbols-outlined align-bottom">group</span> Users</a
|
||||||
><a
|
>
|
||||||
|
{/if}
|
||||||
|
{#if (userState.perms & PERMISSIONS.MANAGE_TAGS) !== 0}
|
||||||
|
<a
|
||||||
href="/admin/tags"
|
href="/admin/tags"
|
||||||
class="{$page.url.pathname === '/admin/tags'
|
class="{page.url.pathname.startsWith('/admin/tags')
|
||||||
? 'primary-underline font-bold'
|
? 'primary-underline font-bold'
|
||||||
: 'low-emphasis-text'} p-2"
|
: 'low-emphasis-text low-emphasis-text-button'} p-2"
|
||||||
><span class="material-symbols-outlined align-bottom">sell</span> Tags</a
|
><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>
|
||||||
|
|
||||||
<div class="base-container">
|
<div class="base-container">
|
||||||
|
|||||||
@ -1,7 +1,17 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { userState } from '$lib/shared.svelte';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
if ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0) {
|
||||||
window.location.href = '/admin/postings';
|
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>
|
</script>
|
||||||
|
|||||||
0
src/routes/admin/companies/+page.server.ts
Normal file
0
src/routes/admin/companies/+page.server.ts
Normal file
0
src/routes/admin/companies/+page.svelte
Normal file
0
src/routes/admin/companies/+page.svelte
Normal file
0
src/routes/admin/companies/[company]/+page.svelte
Normal file
0
src/routes/admin/companies/[company]/+page.svelte
Normal file
0
src/routes/admin/companies/create/+page.svelte
Normal file
0
src/routes/admin/companies/create/+page.svelte
Normal file
0
src/routes/admin/companies/edit/+page.svelte
Normal file
0
src/routes/admin/companies/edit/+page.svelte
Normal file
@ -1,13 +1,15 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
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 { PERMISSIONS } from '$lib/consts';
|
||||||
import { error } from '@sveltejs/kit';
|
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);
|
const perms = getUserPerms(cookies);
|
||||||
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_TAGS) > 0) {
|
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_TAGS) > 0) {
|
||||||
return {
|
return {
|
||||||
tags: await getTags()
|
tags: await getTags(search)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
error(401, 'Unauthorized');
|
error(401, 'Unauthorized');
|
||||||
|
|||||||
@ -1,15 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="content">
|
<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="bottom-border flex place-content-between">
|
||||||
<div class="p-3 font-semibold">
|
<div class="p-3 font-semibold">
|
||||||
Tag Management (Total: {data.tags?.length || 0})
|
Tag Management (Total: {data.tags?.length || 0})
|
||||||
</div>
|
</div>
|
||||||
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/admin/tags/create"
|
<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>
|
</div>
|
||||||
<form action="" class="px-4">
|
<form action="" class="px-4">
|
||||||
@ -40,6 +46,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="left py-1">ID</th>
|
<th class="left py-1">ID</th>
|
||||||
<th class="py-1">Name</th>
|
<th class="py-1">Name</th>
|
||||||
|
<th class="py-1">Created</th>
|
||||||
<th class="py-1"></th>
|
<th class="py-1"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -48,7 +55,8 @@
|
|||||||
{#each data.tags as tag}
|
{#each data.tags as tag}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="left">{tag.id}</td>
|
<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">
|
<td class="w-28 pr-1 text-end">
|
||||||
<a
|
<a
|
||||||
class="hover-bg-color material-symbols-outlined icon-20 my-1 rounded p-1"
|
class="hover-bg-color material-symbols-outlined icon-20 my-1 rounded p-1"
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
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 { PERMISSIONS } from '$lib/consts';
|
||||||
import { error } from '@sveltejs/kit';
|
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);
|
const perms = getUserPerms(cookies);
|
||||||
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) {
|
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) {
|
||||||
return {
|
return {
|
||||||
users: await getUsers()
|
users: await getUsers(search)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
error(401, 'Unauthorized');
|
error(403, 'Unauthorized');
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { userPerms, employerPerms, adminPerms } from '$lib/shared.svelte';
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
@ -6,17 +7,23 @@
|
|||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="content">
|
<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="bottom-border flex place-content-between">
|
||||||
<div class="p-3 font-semibold">
|
<div class="p-3 font-semibold">
|
||||||
User Account Management (Total: {data.users?.length || 0})
|
User Account Management (Total: {data.users?.length || 0})
|
||||||
</div>
|
</div>
|
||||||
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/admin/users/create"
|
<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>
|
</div>
|
||||||
<form action="" class="px-4">
|
<form action="" class="px-4">
|
||||||
@ -45,13 +52,13 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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">Username</th>
|
||||||
<th class="py-1">Permissions</th>
|
<th class="py-1">Permissions</th>
|
||||||
<th class="py-1">Created</th>
|
<th class="py-1">Created</th>
|
||||||
<th class="py-1">Last Sign-In</th>
|
<th class="py-1">Last Sign-In</th>
|
||||||
<th class="py-1">Active</th>
|
<th class="py-1">Active</th>
|
||||||
<th class="py-1"></th>
|
<th class="w-28 py-1"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -60,13 +67,12 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="left">{user.id}</td>
|
<td class="left">{user.id}</td>
|
||||||
<td>{user.username}</td>
|
<td>{user.username}</td>
|
||||||
<td>{user.perms}</td>
|
<td>{user.perms} ({getRoleFromPerms(user.perms)})</td>
|
||||||
<td
|
<td
|
||||||
>{user.created_at?.toLocaleDateString('en-US', dateFormatOptions) ||
|
>{user.createdAt?.toLocaleDateString('en-US', dateFormatOptions) || 'unknown'}</td
|
||||||
'unknown'}</td
|
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
>{user.last_signin?.toLocaleDateString('en-US', dateFormatOptions) ||
|
>{user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions) ||
|
||||||
'unknown'}</td
|
'unknown'}</td
|
||||||
>
|
>
|
||||||
<td class="material-symbols-outlined py-2">
|
<td class="material-symbols-outlined py-2">
|
||||||
@ -74,12 +80,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="w-28 pr-1 text-end">
|
<td class="w-28 pr-1 text-end">
|
||||||
<a
|
<a
|
||||||
class="hover-bg-color material-symbols-outlined icon-20 my-1 rounded p-1"
|
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</a
|
href="/admin/users/{user.id}"
|
||||||
|
>person<span class="tooltip-text font-sans text-sm">View account</span></a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="hover-bg-color material-symbols-outlined icon-20 my-1 ml-1 mr-8 rounded p-1"
|
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</a
|
href="/admin/users/{user.id}/edit"
|
||||||
|
>edit<span class="tooltip-text font-sans text-sm">Edit account</span></a
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
16
src/routes/admin/users/[user]/+page.server.ts
Normal file
16
src/routes/admin/users/[user]/+page.server.ts
Normal 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');
|
||||||
|
};
|
||||||
151
src/routes/admin/users/[user]/+page.svelte
Normal file
151
src/routes/admin/users/[user]/+page.svelte
Normal 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>
|
||||||
120
src/routes/admin/users/[user]/edit/+page.server.ts
Normal file
120
src/routes/admin/users/[user]/edit/+page.server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
420
src/routes/admin/users/[user]/edit/+page.svelte
Normal file
420
src/routes/admin/users/[user]/edit/+page.svelte
Normal 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>
|
||||||
88
src/routes/admin/users/create/+page.server.ts
Normal file
88
src/routes/admin/users/create/+page.server.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
338
src/routes/admin/users/create/+page.svelte
Normal file
338
src/routes/admin/users/create/+page.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
57
src/routes/register/employer/+page.server.ts
Normal file
57
src/routes/register/employer/+page.server.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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>
|
||||||
72
src/routes/register/user/+page.server.ts
Normal file
72
src/routes/register/user/+page.server.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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>
|
||||||
@ -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 { fail, redirect, type Actions, type Cookies } from '@sveltejs/kit';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
dotenv.config({ path: '.env' });
|
dotenv.config({ path: '.env' });
|
||||||
|
|
||||||
function setJWT(cookies: Cookies, username: string, perms: number) {
|
function setJWT(cookies: Cookies, user: User) {
|
||||||
const payload = {
|
const payload = {
|
||||||
username: username,
|
username: user.username,
|
||||||
perms: perms
|
perms: user.perms,
|
||||||
|
id: user.id
|
||||||
};
|
};
|
||||||
|
|
||||||
if (process.env.JWT_SECRET === undefined) {
|
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 maxAge = 60 * 60 * 24 * 30; // 30 days
|
||||||
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
|
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
|
||||||
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false });
|
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false });
|
||||||
console.log(cookies.get('jwt'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions: Actions = {
|
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 }) => {
|
signin: async ({ request, cookies }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const username = data.get('username')?.toString();
|
const username = data.get('username')?.toString().trim();
|
||||||
const password = data.get('password')?.toString();
|
const password = data.get('password')?.toString().trim();
|
||||||
|
|
||||||
if (username && password) {
|
if (username && password && username !== '' && password !== '') {
|
||||||
const perms = await checkUserCreds(username, password);
|
const user: User | null = await checkUserCreds(username, password);
|
||||||
|
|
||||||
if (perms === -1) {
|
if (!user) {
|
||||||
return fail(401, { errorMessage: 'Invalid username or password' });
|
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
|
// redirect to home page
|
||||||
// return { perms: perms };
|
|
||||||
throw redirect(303, '/');
|
throw redirect(303, '/');
|
||||||
} else {
|
} else {
|
||||||
return fail(400, { errorMessage: 'Missing username or password' });
|
return fail(400, { errorMessage: 'Missing username or password' });
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ActionData } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (document.cookie.includes('jwt=')) {
|
if (document.cookie.includes('jwt=')) {
|
||||||
@ -22,33 +23,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// receive form data from server
|
// receive form data from server
|
||||||
let form: ActionData;
|
let { data, form }: PageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="signin-container place-items-center pt-8">
|
<div class="signin-container place-items-center pt-8">
|
||||||
<div class="elevated content rounded-md p-8">
|
<div class="separator-borders elevated content rounded-md p-8">
|
||||||
<h1 class="is-size-3 has-text-weight-semibold my-4">Welcome Back!</h1>
|
<h1 class="text-weight-semibold mb-4 text-center">Welcome Back!</h1>
|
||||||
<form method="POST" class="arrange-vertically">
|
<form method="POST" class="arrange-vertically" use:enhance>
|
||||||
|
<div class="bg-color my-2 rounded">
|
||||||
<input
|
<input
|
||||||
class="input-field my-2 w-full"
|
class="input-field w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
name="username"
|
name="username"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
|
<div class="bg-color mt-4 rounded">
|
||||||
<input
|
<input
|
||||||
type={passwordVisible ? 'text' : 'password'}
|
type={passwordVisible ? 'text' : 'password'}
|
||||||
class="input-field my-2 w-full pr-10"
|
class="input-field w-full pr-10"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={showPassword}
|
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"
|
<span class="material-symbols-outlined"
|
||||||
>{passwordVisible ? 'visibility' : 'visibility_off'}</span
|
>{passwordVisible ? 'visibility' : 'visibility_off'}</span
|
||||||
@ -56,33 +62,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TODO: fix -->
|
|
||||||
{#if form?.errorMessage}
|
{#if form?.errorMessage}
|
||||||
<div class="has-text-danger my-2">{form.errorMessage}</div>
|
<div class="my-2 text-red-500">{form.errorMessage}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<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"
|
type="submit"
|
||||||
formaction="?/signin">Sign In</button
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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 />-->
|
|
||||||
|
|
||||||
<!-- <!– display error message –>-->
|
|
||||||
<!-- {#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>-->
|
|
||||||
|
|||||||
14
src/routes/utils.client.ts
Normal file
14
src/routes/utils.client.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user