start dev

This commit is contained in:
2026-01-18 08:19:01 -06:00
parent 93cfd87821
commit 144cd4787e
18 changed files with 837 additions and 32 deletions
+315
View File
@@ -1,2 +1,317 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
/* defaults */
:root {
--text-color: #000000;
--text-box-bg: #00000010;
--bg-color: #e9e9e9;
--hover-bg-color: #d0d0d0;
--separator-line-color: #a0a0a0;
--low-emphasis-text-color: #6b6b6b;
--primary-color: #1f96f3;
--dull-primary-color: #51aaf0;
--elevated-bg-color: #ffffff;
--bg-accent-color: #f4f4f4;
--danger-color: #ff2d2f;
--hyperlink-color: #3b82f6;
--nav-bg-color: #f0f0f0;
background: var(--bg-color);
color: var(--text-color);
}
.bg-color {
background-color: var(--bg-color);
}
.hover-bg-color:hover {
background-color: var(--hover-bg-color);
}
.low-emphasis-text {
color: var(--low-emphasis-text-color);
}
.low-emphasis-text-button {
color: var(--low-emphasis-text-color);
}
.low-emphasis-text-button:hover {
color: var(--text-color);
}
.primary-underline {
border-bottom: 2px solid var(--primary-color);
}
.top-border {
border-top: 1px solid var(--separator-line-color);
}
.bottom-border {
border-bottom: 1px solid var(--separator-line-color);
}
.left-border {
border-left: 1px solid var(--separator-line-color);
}
.right-border {
border-right: 1px solid var(--separator-line-color);
}
input[type='search'],
input[type='text'],
input[type='password'],
input[type='email'],
input[type='tel'],
input[type='number'],
textarea,
select {
@apply rounded-t;
background-color: var(--text-box-bg);
color: var(--text-color);
border: 0;
border-bottom: 2px solid var(--separator-line-color);
caret-color: var(--text-color);
}
input[type='search']:focus,
input[type='text']:focus,
input[type='password']:focus,
input[type='email']:focus,
input[type='tel']:focus,
input[type='number']:focus,
textarea:focus,
select:focus {
outline: 0 solid var(--text-color);
border-bottom: 2px solid var(--primary-color);
box-shadow: 0 0 0 0 var(--primary-color);
caret-color: var(--text-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,
input[type='number']:-webkit-autofill,
textarea:-webkit-autofill,
select:-webkit-autofill {
-webkit-text-fill-color: var(--text-color) !important;
background-clip: text !important;
caret-color: var(--text-color);
}
input[type='search'].outlined,
input[type='text'].outlined,
input[type='password'].outlined,
input[type='email'].outlined,
input[type='tel'].outlined,
input[type='number'].outlined,
textarea.outlined,
select.outlined {
@apply rounded;
border: 1px solid var(--separator-line-color);
}
input[type='search'].outlined:focus,
input[type='text'].outlined:focus,
input[type='password'].outlined:focus,
input[type='email'].outlined:focus,
input[type='tel'].outlined:focus,
input[type='number'].outlined:focus,
textarea.outlined:focus,
select.outlined:focus {
border: 1px solid var(--primary-color);
}
input[type='checkbox'] {
background-color: var(--text-box-bg);
border: 1px solid var(--separator-line-color);
border-radius: 4px;
}
input[type='checkbox']:focus {
box-shadow: none;
outline: none;
}
.text-box-bg {
background-color: var(--text-box-bg) !important;
}
.separator-borders {
border: 1px solid var(--separator-line-color);
}
.elevated {
background-color: var(--elevated-bg-color);
@apply shadow-lg;
}
.elevated-bg {
background-color: var(--elevated-bg-color);
}
.card {
@apply rounded-lg p-4 block shadow-lg;
background-color: var(--elevated-bg-color);
}
.nav-bg {
background-color: var(--nav-bg-color);
}
.primary-bg-color {
background-color: var(--primary-color);
}
.accent-bg-color {
background-color: var(--bg-accent-color);
}
.dull-primary-bg-color {
background-color: var(--dull-primary-color);
}
.dull-primary-text-color {
color: var(--dull-primary-color);
}
.dull-primary-border-color {
border-color: var(--dull-primary-color);
}
.primary-text-color {
color: var(--primary-color);
}
.danger-bg-color {
background-color: var(--danger-color);
}
.danger-color {
color: var(--danger-color);
}
.danger-border-color {
border-color: var(--danger-color);
}
.hover-hyperlink:hover {
color: var(--hyperlink-color);
text-decoration: underline var(--hyperlink-color);
}
.hyperlink-color {
color: var(--hyperlink-color);
}
.hyperlink-underline {
text-decoration: underline var(--hyperlink-color);
}
.center {
margin: 0 auto;
}
h1 {
font-size: xx-large;
}
h2 {
font-size: x-large;
}
h3 {
font-size: large;
}
button {
cursor: pointer;
}
button.normal, a.normal {
@apply px-2 py-1 leading-tight rounded-lg;
border-width: 1px;
border-color: var(--dull-primary-color);
}
button.large, a.large {
@apply px-4 py-3 leading-tight rounded-xl;
border-width: 1px;
border-color: var(--dull-primary-color);
}
button.wide {
@apply px-4 py-2 leading-tight rounded w-full;
}
button.text {
color: var(--dull-primary-color);
}
button.filled, a.filled {
background-color: var(--dull-primary-color);
}
button.danger {
background-color: var(--danger-color);
border-width: 0;
}
button.outlined-danger {
border-color: var(--danger-color);
}
button.text-danger {
color: var(--danger-color);
border-width: 0;
}
.switch {
display: flex;
border: 1px solid #aaa;
border-radius: 4px;
overflow: hidden;
}
.switch button {
flex: 1;
padding: 0.5rem 1rem;
border: none;
background: #f0f0f0;
cursor: pointer;
}
.switch button.selected {
background: #007acc;
color: white;
}
.modal {
display: none;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgb(0, 0, 0); /* Fallback color */
background-color: rgba(0, 0, 0, 0.6); /* Darken background */
}
.modal-content {
background-color: var(--bg-color);
margin: 10% auto;
padding: 16px;
border: 1px solid var(--separator-line-color);
max-width: 420px;
border-radius: 8px;
}
tr:nth-child(even) {
background-color: rgb(from var(--primary-color) r g b / 10%);
}
+11 -8
View File
@@ -1,11 +1,14 @@
<!--suppress HtmlUnknownTarget -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<title>FBLA26</title>
<link href="%sveltekit.assets%/favicon.png" rel="icon" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
import type { User } from '$lib/types';
import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server';
import type { Cookies } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
export function setJWT(cookies: Cookies, user: User) {
const payload = {
id: user.id,
email: user.email,
name: user.name
};
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
if (process.env.BASE_URL === undefined) {
throw new Error('BASE_URL not defined');
}
const secure: boolean = process.env.BASE_URL?.includes('https://');
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, secure });
}
// export function checkPerms(cookies: Cookies, perms: number): void {
// const JWT = cookies.get('jwt');
// if (!JWT) throw error(403, 'Unauthorized');
// if (process.env.JWT_SECRET === undefined) {
// throw new Error('JWT_SECRET not defined');
// }
// const user = jwt.verify(JWT, process.env.JWT_SECRET) as User;
// if ((user.perms & perms) !== perms) {
// throw error(403, 'Unauthorized');
// }
// }
//
// export function hasPerms(cookies: Cookies, perms: number): boolean {
// const JWT = cookies.get('jwt');
// if (!JWT) return false;
// if (process.env.JWT_SECRET === undefined) {
// throw new Error('JWT_SECRET not defined');
// }
// const user = jwt.verify(JWT, process.env.JWT_SECRET) as User;
// return (user.perms & perms) === perms;
// }
export async function login(email: string, password: string): Promise<User> {
try {
const [user] = await sql`
SELECT id, email, password_hash, perms, name
WHERE email = ${email};
`;
if (await bcrypt.compare(password, user.password_hash)) {
delete user.password_hash;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
sql`
UPDATE users
SET last_signin = NOW()
WHERE id = ${user.id};
`;
return <User>user;
}
} catch {
throw Error('Error signing in ');
}
throw Error('Invalid email or password');
}
// await createUser(<User>{
// email: 'drake@marinodev.com',
// password: 'password',
// perms: 255,
// name: 'Drake'
// });
+9
View File
@@ -0,0 +1,9 @@
export const PERMISSIONS = {
VIEW: 0b00000001,
DO_TASKS: 0b00000010,
MANAGE_USERS: 0b00000100,
MANAGE_ITEMS: 0b00001000,
MANAGE_TASKS: 0b00010000
};
export const EXPIRE_REMINDER_DAYS = 30;
+14
View File
@@ -0,0 +1,14 @@
import postgres from 'postgres';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env' });
const sql = postgres({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT!),
database: process.env.POSTGRES_DB,
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD
});
export default sql;
+67
View File
@@ -0,0 +1,67 @@
import type { User } from '$lib/types';
import sql from '$lib/db/db.server';
import bcrypt from 'bcrypt';
// should require MANAGE_USERS permission
export async function getUsers(searchQuery: string | null = null): Promise<User[]> {
return sql`
SELECT id,
email,
perms,
name,
created_at AT TIME ZONE 'UTC' AS "createdAt",
last_signin AT TIME ZONE 'UTC' AS "lastSignIn"
FROM users
WHERE (${!searchQuery ? sql`TRUE` : sql`email ILIKE ${'%' + searchQuery + '%'} OR name ILIKE ${'%' + searchQuery + '%'}`});
`;
}
export async function getUser(id: number): Promise<User> {
return <User>(
await sql`
SELECT id,
email,
perms,
name,
created_at AT TIME ZONE 'UTC' AS "createdAt",
last_signin AT TIME ZONE 'UTC' AS "lastSignIn"
FROM users
WHERE (id = ${id}) LIMIT 1;
`
)[0];
}
export async function createUser(user: User) {
const passwordHash = await bcrypt.hash(user.password!, 12);
await sql`
INSERT INTO users (email, password_hash, perms, name, created_at, last_signin)
VALUES (${user.email}, ${passwordHash}, ${user.perms}, ${user.name}, NOW(), NOW())
RETURNING id;
`;
}
export async function editUser(user: User) {
let passwordHash: string | undefined;
if (user.password) {
passwordHash = await bcrypt.hash(user.password, 12);
}
await sql`
UPDATE users
SET
email = ${user.email},
${passwordHash ? sql`password_hash = ${passwordHash},` : sql``}
perms = ${user.perms},
name = ${user.name}
WHERE id = ${user.id!}
RETURNING id;
`;
}
export async function deleteUser(id: number) {
await sql`
DELETE FROM users
WHERE id = ${id}
`;
}
+21
View File
@@ -0,0 +1,21 @@
export interface User {
id: number;
email: string;
phone: string;
password?: string;
name: string;
createdAt: Date;
lastSignIn: Date;
}
export interface Item {
id: number;
ownerEmail: string;
ownerPhone: string;
foundDate: Date;
approvedDate?: Date;
claimedDate?: Date;
title: string;
description: string;
transferred: boolean; // to L&F location
}
+41
View File
@@ -0,0 +1,41 @@
import type { User } from '$lib/types';
export const getCookieValue = (name: string): string =>
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
export const getUserFromJWT = (jwt: string): User => JSON.parse(atob(jwt.split('.')[1]));
// export const userData = () => {
// let userData: User | null = null;
// const cookieValue = getCookieValue('jwt');
// if (cookieValue) {
// userData = getUserFromJWT(cookieValue);
// }
// return userData;
// };
export function getFormString(data: FormData, key: string): string | undefined {
const value = data.get(key);
if (value === null) {
return undefined;
}
if (typeof value !== 'string') {
throw Error(`Incorrect input in field ${key}.`);
}
return value.trim();
}
export function getRequiredFormString(data: FormData, key: string) {
const value = data.get(key);
if (typeof value !== 'string') {
throw Error(`Missing required field ${key}.`);
}
return value.trim();
}
export const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
+10
View File
@@ -0,0 +1,10 @@
import type { LayoutServerLoad } from './$types';
import { getUserFromJWT } from '$lib/utils';
export const load: LayoutServerLoad = ({ cookies }) => {
const jwt = cookies.get('jwt');
if (jwt) {
return { selfUser: getUserFromJWT(jwt) };
}
return { selfUser: null };
};
+24 -7
View File
@@ -1,12 +1,29 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
let { children } = $props();
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<link
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,cloud_upload,dark_mode,delete,description,edit,group,home,info,light_mode,login,mail,menu,open_in_new,person,search,sell,store,upload,visibility,visibility_off,work"
rel="stylesheet"
/>
{@render children?.()}
<!--<header class="bottom-border flex justify-between">-->
<!-- <a class="material-symbols-outlined p-2 !text-3xl leading-none" href="/">home</a>-->
<!-- {#if data.selfUser}-->
<!-- <a class="material-symbols-outlined p-2 !text-3xl leading-none" href="/account"-->
<!-- >account_circle</a-->
<!-- >-->
<!-- {:else}-->
<!-- <a class="material-symbols-outlined p-2 !text-3xl leading-none" href="/signin">login</a>-->
<!-- {/if}-->
<!--</header>-->
<div style="max-width: 800px;" class="center">
{@render children?.()}
</div>
<footer>
</footer>
+4
View File
@@ -1,2 +1,6 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
View File
View File